Skip to main content
Cookbooks

Hold a Spend for Approval

Write an approval-required rule that stops a spend before dispatch, poll the hold from the caller side, decide it as an operator, and read which refusal a retry can fix.

2 min read
View MarkdownEdit on GitHub
TIME
~20 min
STACK
@codespar/sdkcurlno CLI command covers this
SERVERS
any governed toolwallet:paywallet:withdraw

Write an approval-required rule, watch it hold a matching spend before the provider is ever called, poll the hold from the agent side, and decide it from an operator seat.

HOLD AND DECIDE
Rule matches before dispatch, caller polls, operator decides, approved calls replay
1 · CALL
Agent calls a governed tool
POST /v1/sessions/{id}/proxy_execute
2 · HOLD
Rule matches, nothing dispatches
row apv_… + approval_pending, one transaction
3 · DECIDE
Admin approves or denies
POST /v1/approvals/{id}/decide
STEP 1
approval_call_attempted
witness written before the provider is touched
STEP 2
provider call
outside the decide transaction, PROXY_MODE=real only
STEP 3
approval_executed
or approval_execution_failed, with execution_result
the decide handler re-reads the row after the replay, so its 200 can carry status: execution_failed

Prerequisites

npm install @codespar/sdk

Three credentials, and they are not interchangeable.

StepCredentialWhy
Create and order the ruleService credential: x-codespar-service-key plus x-codespar-orgA bearer key is refused by a plugin hook before any policy handler runs
Call the tool, poll the holdProject API key (csk_…)This is the only part of the journey a project key completes end to end
Decide the holdService credential, plus an org member holding at least adminA bearer credential is refused on the first check of the decide route

The dashboard does both service-credential steps for you: rules live at /dashboard/policies, the queue at /dashboard/approvals.

The served reference at POST /v1/policies opens with a Bearer token and sends Authorization: Bearer $CODESPAR_API_KEY in every example. Those examples cannot work. All six policy operations answer 403 bearer_token_cannot_manage_policies to any caller that is not service auth, and that refusal is not in the served document either.

1. Write the rule

type is approval-required. The match is (agents, tools) by glob, with * as the only metacharacter.

curl -X POST https://api.codespar.dev/v1/policies \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "content-type: application/json" \
  -d '{
    "name": "Hold payouts for review",
    "type": "approval-required",
    "agents": ["*"],
    "tools": ["wallet:pay", "wallet:withdraw", "asaas:post:transfers"],
    "config": {},
    "enabled": true
  }'

The 201 returns the Policy, including its pol_… id. Keep it: step 2 needs it.

A rule created with the schema defaults never fires. PolicyCreate defaults both agents and tools to [], and the matcher iterates the array and returns false when it is empty. An approval-required rule with no tools and no agents is inert, and the 201 looks exactly like a working one. Always send both arrays.

config accepts an approvers array. It is validated and stored, and nothing reads it. Who can decide is decided by org role, not by that list.

2. Move the rule above every allow rule that matches

Creation inserts at MAX(sort_order) + 1, so a new rule lands last. The engine scans in order and stops at the first match. One earlier allow rule covering the same pair swallows the hold completely, and nothing warns you.

curl -X POST https://api.codespar.dev/v1/policies/reorder \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "content-type: application/json" \
  -d '{ "ids": ["pol_hold_payouts", "pol_allow_reads", "pol_daily_budget"] }'

204, no body. Send every rule id you have. The endpoint accepts a partial list and assigns index + 1 only to the ids you sent, so posting 2 of 5 rules leaves the other three tied at positions you did not choose.

The rule cache is per process with a 55 second TTL, and invalidation only clears the process that took the write. On a multi-replica deployment a new rule can take up to 55 seconds to bind everywhere. Confirm with a real call, not with the 201.

3. The call gets held

The guard runs before the route handler, so the provider is never touched. What the caller receives depends on which path it used.

PathShape of the hold
POST /v1/sessions/{id}/proxy_execute403 with { reason, ruleType, ruleId, approval_id, expires_at }
POST /v1/sessions/{id}/executeThe same 403, and in the catalog branch a 200 whose error opens with the approval_required: prefix
POST /v1/sessions/{id}/sendA structured tool result: { code: "approval_required", approval_id, expires_at, message }
POST /v1/wallets/{id}/execute and /transferThe same 403 envelope, plus an error field the session guard does not send

That 403 envelope mixes conventions in one object: reason, ruleType and ruleId in camelCase, approval_id and expires_at in snake_case.

agent/spend.ts
import { CodeSpar, CodesparApiError } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const session = await cs.create("user_123", { servers: ["asaas"] });

interface HeldCall {
  reason?: string;
  ruleType?: string;
  ruleId?: string;
  approval_id?: string;
  expires_at?: string;
}

try {
  const res = await session.proxyExecute({
    server: "asaas",
    endpoint: "/v3/transfers",
    method: "POST",
    body: { value: 250.0, pixAddressKey: "loja@example.com" },
  });
  console.log("dispatched", res.status, res.data);
} catch (e) {
  if (!(e instanceof CodesparApiError) || e.status !== 403) throw e;

  const held = e.body as HeldCall;
  if (!held.approval_id) {
    // A deny rule matched, not an approval rule. There is no hold to decide.
    throw e;
  }
  console.log("held", held.approval_id, "by rule", held.ruleId, "until", held.expires_at);
}

proxyExecute throws CodesparApiError and hands you the parsed body. execute does not throw on 403: it returns { success: false, data: null, error: "403: <raw body text>" }, so the approval id is inside a string you have to parse. Prefer proxyExecute when you plan to catch a hold, and note that it is also the only session path that records the real tool_input.

In the chat loop the hold arrives as a tool result, and the SDK ships a guard for it.

agent/chat.ts
import { CodeSpar, isApprovalRequired } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const session = await cs.create("user_123", { preset: "brazilian" });

for await (const event of session.sendStream("Pay R$250 to loja@example.com")) {
  if (event.type !== "tool_result") continue;
  const out = event.toolCall.output;
  if (isApprovalRequired(out)) {
    console.log("held", out.approval_id, "until", out.expires_at);
  }
}

There is a third way to find the hold, and it needs no string parsing and no exception. Every match also writes an evaluation row carrying the approval id.

const rows = await cs.api.get("/v1/policy-evaluations", { query: { limit: 20 } });
// Each row carries decision and approvalId. GET /v1/evaluations is a deprecated
// alias of the same handler.

4. Poll the hold

There is one poll endpoint, and a project key can use it. It is scoped to the caller's org and project.

agent/wait-for-approval.ts
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function waitForApproval(id: string, timeoutMs = 15 * 60_000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const status = await cs.api.get("/v1/approvals/{id}/status", { path: { id } });

    // approval_status: pending | approved | denied | expired | execution_failed
    if (status.approval_status !== "pending") {
      return status; // carries decided_by, decision_reason, execution_result
    }
    await sleep(5_000);
  }
  throw new Error(`approval ${id} is still pending after ${timeoutMs} ms`);
}

A missing or out-of-scope id answers 404 approval_not_found here. The operator read at GET /v1/approvals/{id} answers 404 not_found instead. Two different literals for the same idea, on two endpoints in the same journey.

A hold past its expiry still reads pending. The decide route compares expires_at in its update and answers 410, but the status column only flips to expired when the expiry sweep runs, and that sweep is a separate job, not part of the API server. So this poll can return pending forever for a row that nobody can decide any more. Compare expires_at against the clock yourself and stop polling when it passes.

5. Read the queue and decide

The two operator reads are org-scoped, not project-scoped, and they accept a project key as well. Only the decide write refuses one. That asymmetry has a consequence: a key bound to one project sees holds raised under sibling projects in this queue, and cannot poll any of them at step 4.

# Pending only. Omitting status does NOT mean "all".
curl https://api.codespar.dev/v1/approvals \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID"

# History, once you know you want it
curl -G https://api.codespar.dev/v1/approvals \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  --data-urlencode "status=approved,denied,expired"

The response is a raw array, with no envelope and no cursor. limit defaults to 100 and is capped at 100, so past the 100 most recent rows there is no second page. A status value outside the enum, or a limit outside 1 to 100, answers 400 invalid_query.

Read one row to see what is actually being approved.

curl https://api.codespar.dev/v1/approvals/apv_8ZqK3m1e4xW7tb \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID"

Two fields on that row mislead if you read them as identifiers. tool_name has no single format: the proxy lane writes <server>:<method>:<endpoint> with the slashes turned into colons, the execute lane writes whatever string the caller sent, the over-cap settlement lane writes codespar_pay, and the operator lanes write admin:account_status_set or admin:withdrawal_resolve. matched_rule_id is not always a pol_ id either: the non-overridable deny list and the settlement lanes write their own literals, such as boleto-over-cap, so GET /v1/policies/{id} with that value answers 404 for most holds.

Then decide it. Both fields are required, and reason must be 1 to 8000 characters of something real.

curl -X POST https://api.codespar.dev/v1/approvals/apv_8ZqK3m1e4xW7tb/decide \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "x-codespar-user-token: $CLERK_USER_TOKEN" \
  -H "x-codespar-user: $CLERK_USER_ID" \
  -H "content-type: application/json" \
  -d '{
    "decision": "approve",
    "reason": "Vendor invoice 4471 matches the purchase order."
  }'

The order the checks run in

This is what tells you how much already happened when a refusal lands.

  1. Org mismatch on the id, answered 404.
  2. Identity gate: bearer refused, then user token verified, then role floor. 403 or 503.
  3. Rate limit for the org. 429.
  4. Body validation. 400.
  5. Only then the transaction: read the row's project, compare-and-set WHERE id AND org_id AND status = 'pending' AND expires_at > now, and on a hit write the decision, mirror it into the evaluation row, and append approval_decided, all in the same BEGIN.

The rate limit is charged before the body is validated. A POST with an empty reason burns a token from the bucket and still comes back 400. The bucket is 30 per minute per org by default, and it is in-memory per process: with several replicas the effective ceiling is 30 per minute per replica.

What the decide route answers

2xxWorked
200Decided. The body is the row re-read after the replay, so it can say status: "execution_failed".
4xxYour request
400reason_invalid for empty, whitespace-only or over 8000 characters; invalid_body for any other schema problem, such as a decision outside approve and deny.
403bearer_token_cannot_decide, user_token_required, user_token_invalid, user_token_identity_mismatch or insufficient_role. The refusal names which one.
404not_found. The id does not exist, or belongs to another org. The two are indistinguishable on purpose: a 403 would confirm the id is real.
409already_decided. The body carries the complete approval, so you can see who decided what without a second read.
410expired. The hold aged out before anyone decided. The body also carries the complete approval.
429rate_limit_exceeded, with retry_after_seconds in the body and a Retry-After header.
5xxOur side, or an upstream
503user_token_verification_unconfigured. The deployment has no CLERK_ISSUER_URL. Nothing about the request is wrong.

Which refusal a retry fixes

RefusalWhat already happenedDoes a retry help
403 bearer_token_cannot_decideNothing. First check on the route, never gated by a flagNo, ever. A bearer key cannot approve a spend it could have asked for. Use the service path
403 user_token_requiredNothing. Only fires when APPROVAL_DECIDE_ENFORCE_USER_TOKEN is onYes, once you send x-codespar-user-token
403 user_token_invalidNothing. Signature, issuer, expiry or algorithm failed. Never gatedYes, with a fresh Clerk token. They are short-lived
403 user_token_identity_mismatchNothing. x-codespar-user and the token sub name different peopleYes, if both come from the same auth call, or send only the token
403 insufficient_roleNothing. The verified person is below admin. The body says required: "admin"No. Promote the person, or have an admin decide
429 rate_limit_exceededNothing decided, one bucket token spentYes, after retry_after_seconds
400 reason_invalidNothing decided, one bucket token spentYes, with a real reason
409 already_decidedThe row left pending earlier. Your decision was not appliedNo. Deciding is not idempotent, and a second call is not a silent no-op
410 expiredThe hold aged out. Nothing ranNo. A hold cannot be reopened. The agent has to ask again
503 user_token_verification_unconfiguredNothing. The deployment is missing an env varNo. Set CLERK_ISSUER_URL and restart the service, because the env loader memoizes

Dual control is shipped but not switched on. Requiring the verified user token is gated on APPROVAL_DECIDE_ENFORCE_USER_TOKEN, which defaults to false. While it is off, a service-key holder decides by naming any admin in the x-codespar-user header, and the audit chain records that as approval.decide_unverified with decided_by_source: "header_asserted". Verification itself is never gated: send a token and you get the verified path today. One edge to know about: with the flag off, a caller that sends a token and no header still gets the 503 when the deployment cannot verify. And with the flag off, no token and no header answers insufficient_role, not user_token_required.

What approve actually does

Denying ends there. Approving starts a replay that runs outside the decide transaction, in three parts: a witness event approval_call_attempted, the provider call, then approval_executed on a 2xx or approval_execution_failed on anything else, carrying execution_result with ok, upstream_status, data, error and duration_ms.

The replay's idempotency key is imposed by the server as apv-<approval_id>. Any Idempotency-Key the agent sent is kept in tool_input for correlation only.

Approving a hold raised on /execute does not replay anything useful. That path mounts the guard without a tool-input resolver, so the engine stores {}. The replay then reads server, method and endpoint out of that empty object, gets empty strings, and the row ends as execution_failed with credential_unavailable or proxy_mode_unconfigured. The same applies to wallet:pay and wallet:withdraw, whose recorded input is { amount, currency, recipient } rather than a call. Only proxy_execute records a call the replay can rebuild.

A 200 from decide is not proof the money moved. The handler re-reads the row after the replay and returns that state, so status can be execution_failed inside a 200. Branch on status and execution_result, never on the HTTP code.

The replay only calls a real provider when PROXY_MODE=real. With PROXY_MODE=mock or NODE_ENV=test the result is stamped mocked: true. Any other value answers proxy_mode_unconfigured and the row becomes execution_failed, on purpose, so the chain never holds an approval_executed that cannot be told apart from a real one.

Header stripping is wider than the spec describes. The document mentions authorization and cookie; the sanitizer also drops any header key ending in -token or -key, case-insensitive.

Read the chain

Five event types cover the whole journey, and event_type is an exact match unless the value ends in a dot. These five are underscore-named, so no prefix catches them together: that is five queries.

for t in approval_pending approval_decided approval_call_attempted \
         approval_executed approval_execution_failed; do
  curl -s -G https://api.codespar.dev/v1/audit-events \
    -H "authorization: Bearer $CODESPAR_API_KEY" \
    --data-urlencode "event_type=$t" \
    --data-urlencode "limit=100"
done

The default window is the last 7 days. Pass from and to for anything older, and before_sequence to walk backwards. A project key reads this endpoint, which makes the chain the one place a caller-side key can reconstruct what happened without operator access.

GET /v1/approvals/health reports pending_count and oldest_pending_age_seconds for your own org, but last_sweep_at is not yours: the sweep log has one row per sweep type and no org column, so that timestamp moves for reasons unrelated to your queue. Read it as "sweeps are alive on this deployment", never as "my queue was swept".

What this does not do

  • No way to create a hold. There is no POST /v1/approvals. A hold is born only from an approval-required rule that matched, or from an internal guard such as the boleto mandate cap or the operator lanes.
  • No amount threshold. An approval-required rule matches on agents and tools, nothing else. "Hold anything above R$ 5.000" is not expressible: a budget rule denies, it does not hold. Guardrails currently claims otherwise, and the rule grammar does not support it. A budget rule is also a poor substitute from the SDK, because session.execute sends no estimatedCost and the guard reads that as a cost of zero.
  • No per-rule expiry. APPROVAL_TTL_HOURS is deployment-wide and defaults to 24. Nothing in the create body takes a deadline.
  • No webhook, no trigger, no stream. No approval.* event is published anywhere, and the poll has no streaming variant. Poll, or read the audit chain.
  • config.approvers is decorative. It is validated and stored, and never read. Anyone holding admin in the org can decide any hold in it.
  • No status alias under /v1/orgs. The org-prefixed aliases cover list, read, health and decide. The caller poll exists only at the canonical path.
  • No CLI command. approvals, policies, policy-evaluations and evaluations are all declared outside the CLI surface on purpose.
  • No SDK namespace. There is no cs.approvals and no session.approvals, only the generic REST client. cs.api.post("/v1/approvals/{id}/decide", …) compiles and always answers 403 at runtime, because the client can only build an Authorization: Bearer header, and a bearer credential is exactly what the route refuses. The six policy operations fail the same way. There is also no way to attach x-codespar-user-token through the typed client: the operation declares no headers, so TypeScript rejects the key.
  • The served document is incomplete here. It does not describe the bearer refusal on the policy routes, it types PolicyCreate.config as an open object with no per-type shape, and its PolicyEvaluation schema is missing approvalId, decidedAt and decidedBy while its decision enum has no approval_required value the handler actually returns. The 404 it documents on the approvals list is unreachable at the canonical path: only the /v1/orgs/{orgId}/approvals alias, called with someone else's org, produces it.

Next steps

Hold a Spend for Approval | CodeSpar