---
title: Guardrails
description: The policy layer that gates every agent tool call before it dispatches, spend budgets, rate limits, and human approval, enforced server-side.
---

import { Callout } from "fumadocs-ui/components/callout";

# Guardrails

<Callout title="Server-side, not advisory" type="info">
Guardrails run in the CodeSpar runtime, **before** a tool call reaches a provider, not in your agent's prompt. A blocked call never dispatches, no matter what the model decided. The same rules apply whether the call came from `session.execute()` or the chat-loop `session.send()`.
</Callout>

Guardrails let you bound what an agent is allowed to do with money. You define policies per project; the runtime evaluates every governed tool call against them and either allows it, blocks it, or holds it for human approval. Every decision is written to the [audit chain](/docs/concepts/audit-chain).

## Policy types

| Policy | What it bounds |
|---|---|
| **Budget** | A spend cap over a window, e.g. R$ 5.000/day or a monthly ceiling per project. Charges and payouts that would exceed it are blocked. |
| **Rate limit** | How often a tool can be called: calls per minute / per hour, enforced with durable counters (not best-effort in-memory). |
| **Approval required** | Routes a matching call to a human before it executes. The call is held; an operator approves or rejects it in **/dashboard/approvals**; on approval it dispatches, on rejection it never does. |

Policies can scope to a tool, a meta-tool, an amount threshold, or a time window, and compose. A payout might be inside budget but still need approval above a threshold.

<Callout title="What this page does not cover" type="warn">
The internal scoring and risk logic CodeSpar uses to evaluate fraud and anomaly signals is proprietary and intentionally out of scope here. This page documents the **policy surface you control**, not the engine internals.
</Callout>

## How a decision flows

```
agent calls a tool
  ↓
guardrails evaluate the call against the project's policies
  ↓                    ↓                         ↓
allow                deny                  approval-required
  ↓                    ↓                         ↓
dispatches      returns a deny result      held → /dashboard/approvals
                                            → approve → dispatches
  ↓                    ↓                         ↓
every outcome is appended to the audit chain
```

## Approvals

When a call matches an approval-required rule, it does not dispatch. The runtime holds it and writes a row to the approvals queue at **/dashboard/approvals**, where an operator with admin or owner role approves or rejects it with a reason. Every held call carries an expiry: if nobody decides in time, the approval expires and the call never runs.

On the caller's side, the blocked call returns an approval id. Your agent or SDK can poll for the outcome:

```
GET /v1/approvals/:id/status
```

The endpoint is authenticated with your normal API key and scoped to your org and project. `approval_status` is one of:

| Status | Meaning |
|---|---|
| `pending` | Awaiting an operator decision. The only non-terminal status; keep polling. |
| `approved` | Decided and executed. The held call is re-executed server-side; `execution_result` in the response carries the upstream outcome. |
| `denied` | The operator rejected it. Nothing executed. |
| `expired` | The expiry elapsed before a decision. Nothing executed. |
| `execution_failed` | Approved, but the replayed call errored. `execution_result.error` explains. |

Policies themselves are created and ordered in the dashboard at **/dashboard/policies**.

## Chat-loop coverage

The chat loop is the natural-language tool-use path: `session.send("Charge R$5 via Pix")` runs a tool-use loop on the backend, picks the tools, and dispatches each one through the runtime. Every tool dispatch inside that loop runs through the same guardrail hooks, in the same fixed order, as a direct `session.execute()`:

1. **Deny-list.** Non-overridable safety rails ([Projects](/docs/concepts/projects)). A test cannot mock past these.
2. **Policy evaluation.** Your rules evaluate against the tool name plus the input the model picked. `allow`, `deny`, or `approval-required`.
3. **Session mock store** (test-mode sessions only). See [Test mode](/docs/concepts/test-mode).
4. **Upstream provider call**, if not denied, held, or mocked.
5. **Audit chain append.** Every completed call writes a `tool_call.succeeded` or `tool_call.failed` event, same event names and shape as the direct-execute path, so existing audit and SIEM queries pick chat-loop events up without changes.
6. **Commerce-memory capture**, when the per-tool capture predicate matches.

Rules are tool-and-input scoped, not call-site scoped. A rule matching a payments tool fires whether your code named the tool or the model picked it inside `session.send()`; you do not need to duplicate rules per path. There is no "allow direct execute but deny chat-loop" distinction in the rule grammar. The conservative pattern for high-blast-radius tools is `approval-required`: the chat loop hits the approvals queue and your operators decide per call. When an approval-required rule fires inside the loop, the loop receives a structured `approval_required` tool result and the same queue row is written, so check **/dashboard/approvals** volume after your next chat-loop test run.

The cleanest way to confirm coverage is a test-mode session that mocks the relevant tool. The chat loop runs the hooks identically to direct execute, so one fixture round-trip proves both paths:

```ts
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });

const session = await cs.create("user_test", {
  servers: ["asaas"],
  mocks: {
    "asaas/create_payment": { id: "pay_test_42", status: "PENDING" },
  },
});

const result = await session.send("Charge R$5 via Pix");

// The loop picked asaas/create_payment, the policy engine evaluated it,
// the mock store returned the fixture, and the audit chain recorded it.
const charge = result.tool_calls.find((tc) => tc.tool_name === "asaas/create_payment");
expect(charge?.output?.id).toBe("pay_test_42");
```

See [Test mode](/docs/concepts/test-mode) for the full mocks contract and the type-narrowed guards (`isPolicyDenied`, `isApprovalRequired`) for parsing tool-result envelopes.

## See also

- [Audit chain](/docs/concepts/audit-chain): every guardrail decision is recorded
- [Wallets](/docs/concepts/wallets): programmable spend limits on agent wallets
- [Test mode](/docs/concepts/test-mode): policy and audit hooks fire on mocked calls too
