Connecting an agent to Sealgraf
This is the integration kit for connecting an agent to Sealgraf. Follow it verbatim; everything an agent needs is in here, and the MCP tool descriptions repeat the rules that matter most, so an agent that reads only those still behaves correctly.
What Sealgraf is
Sealgraf checks an AI agent's proposed purchases against a permission slip (a mandate) that the agent's owner set in the Sealgraf app: per-purchase cap, period cap, allowed categories, an ask-me-first threshold. Sealgraf checks and records — it never moves money. The agent still buys through its own channels; Sealgraf decides whether the purchase fits the slip and seals a receipt as independently verifiable evidence when a checked spend completes.
Slips live in Sealgraf and are set by the owner. An agent can never create, raise, or edit a slip — not through MCP, not through the API.
Step 1 — the owner issues an agent key
- The owner signs in to Sealgraf and creates a slip for the agent (Permissions → "Set limits for another agent", or the connect flow). The connect flow's own key IS the agent key: it is issued with Agent scope, tied to that first slip — it can run the spend loop on that slip and nothing else. Steps below are for additional agents, or for replacing it.
- For another key, the owner opens Keys → New key:
- Label: something recognizable, e.g.
Shopping runs. - Scope: Agent (checks spends and completes them — nothing else).
- Tie it to one slip: pick the agent's slip. Do this. A key tied to one slip can never touch another, even in the same workspace — and it is refused tenant-wide operations entirely (no new slips, no checkpoints, no key management), whatever its scope.
- Label: something recognizable, e.g.
- The key's secret is shown exactly once. Copy it into the agent's configuration (Step 2). If the secret is lost, revoke the key and create a new one.
- Hygiene: one key per agent. Revoking a key (Keys → Revoke, or
DELETE /v1/api-keys/{key_id}) takes effect immediately — the key's very next call is refused. Rotate withPOST /v1/api-keys/{key_id}/rotate(the new secret is shown once; the old secret stops working at once). Rotation refuses a revoked or expired key (409) — for those, create a fresh key; rotation never resurrects a dead one.
If an approval is still pending when you rotate, finish or let it lapse first: a rotated successor key cannot claim the old key's approval tokens (claims belong to the key that ran the check), so an in-flight approval is completed by the owner, aborted, or re-checked under the new key.
Key scopes, for reference: agent = exactly the spend loop (check, complete, read its own preauthorizations and receipts — it cannot read a slip's terms, usage, or verification state); readonly = all reads, no writes; admin = everything, including slips and key management. Never give an agent an admin key. The owner's own admin path is their signed-in session.
Step 2 — point the agent at the MCP door
The MCP endpoint is hosted on the Sealgraf service itself:
https://<your-sealgraf-host>/mcp
Every call carries the agent key in the X-API-Key header (Authorization: Bearer <key> is also accepted). The key selects the workspace — there is nothing else to configure, and no tenant id is ever sent.
Generic MCP client configuration (streamable HTTP):
{
"mcpServers": {
"see": {
"url": "https://<your-sealgraf-host>/mcp",
"headers": { "X-API-Key": "see_<the-secret-from-step-1>" }
}
}
}
Claude Code, from a terminal:
claude mcp add --transport http see https://<your-sealgraf-host>/mcp \
--header "X-API-Key: see_<the-secret-from-step-1>"
(Other hosts take the same shape: a streamable-HTTP MCP server entry with the URL above and the X-API-Key header.)
No MCP client? Everything the tools do is also plain REST with the same key header — see "REST equivalents" below.
Step 3 — the tools
Exactly two. There is no completion tool in MCP, on purpose: finishing a spend — claiming an approval's decision token and submitting completion evidence — is a REST act (Step 4), so a tool-using agent can ask and read over MCP, and the spend itself closes only through an explicit REST call carrying the token and the evidence.
check_permission — ask BEFORE buying
Inputs:
| field | type | notes |
|---|---|---|
mandate_id | string | the slip's id (the owner can read it off the slip) |
agent_id | string | must match the agent named on the slip |
category | string | must be one of the slip's categories |
amount_minor | integer | amount in the currency's minor unit (cents), e.g. $42.50 → 4250 |
currency | string | must match the slip's currency, e.g. USD |
counterparty | string | who would be paid (optional, recorded) |
description | string | what it is (optional, recorded) |
idempotency_key | string | optional, 8–200 chars. Repeating a check with the same key and the same action returns the original check (marked "replayed": true) instead of reserving twice; the same key with a different action is refused (Conflict) |
Arguments are validated strictly: a value of the wrong JSON type (a string amount, a float, a boolean) is refused, never coerced, and any field outside this table is refused by name — nothing is silently dropped.
Never pass a tenant id. Tenant context is derived from the authenticated key, never from call arguments: passing tenant_id, tenant, or tenantId refuses the call.
Output (success):
{
"preauth_id": "pa_…",
"decision": { "outcome": "allow", "reason_code": "ok",
"mandate_id": "mnd_…", "mandate_version": 1 },
"state": "authorized",
"action": { "agent_id": "…", "category": "…", "amount_minor": 4250,
"currency": "USD", "counterparty": "…", "description": "…" },
"action_hash": "…",
"token_expires_at": "…", // when the decision token dies (null on a deny)
"ask_expires_at": "…", // pending asks only: when the owner's decision window closes
"decision_token": "…" // only when outcome is "allow"
}
decision.outcome is one of:
- **
allow* — the purchase fits the slip. The amount is now reserved* against the slip's period cap, anddecision_tokenis a short-lived, single-use token bound to this exact action (this amount, this category, this counterparty). Its expiry is thetoken_expires_aton the check record — returned in the REST check response, and shown when you poll the check (Step 4). Buy, then complete (Step 4) with that token, before it expires. - **
needs_human_approval— the purchase fits the slip's caps but is above the owner's ask-me-first threshold. No token is issued to you.** Stop and wait (Step 4). The ask stays decidable untilask_expires_at— 24 hours from the check (see "The two windows" below). If the owner approves, a decision token is issued for you at approval time — Step 4.3 shows how to claim it and finish the spend yourself. Do not buy yet; do not re-check in a tight loop. - **
deny** — the purchase does not fit the slip. Do not buy. Thereason_codesays which rule refused it (table in Step 5). A denial is final unless the owner changes the slip.
get_receipt — read the sealed evidence
Input: receipt_id. Output: the receipt's id, its preauth_id, the amount/currency, and the full DSSE envelope — the signed evidence, verifiable offline by anyone. A key can only read its own workspace's receipts (and, if the key is slip-tied, only that slip's). Anyone holding a receipt's proof bundle can also check it as a stranger, no key needed, on the Sealgraf Check a receipt page — and the receipt format behind it is open.
Step 4 — the spend flow, end to end
- Check. Call
check_permissionbefore every purchase, with the exact amount and counterparty you are about to pay. - **On
allow:** you hold adecision_token. Buy through your normal channel, then submit completion evidence over REST:
``bash curl -X POST https://<your-sealgraf-host>/v1/preauthorize/<preauth_id>/complete \ -H "X-API-Key: see_<the-secret>" -H "Content-Type: application/json" \ -d '{ "decision_token": "<token from the check>", "completion_nonce": "<a unique id for this completion>", "evidence": { "type": "order_confirmation", "counterparty_receipt_ref": "<order id>", "note": "<what was bought>" } }' ``
Evidence seals exactly these fields: completed_amount_minor (optional; defaults to the authorized amount), counterparty_receipt_ref, note, and completed_at. The annotations type and source are also accepted but are not sealed. Any other evidence key is refused (ValidationFailed, naming the key) and nothing is sealed — a refused completion can be corrected and retried with the same nonce.
The response is the sealed receipt (receipt_id, amounts, hashes). The completion amount may be less than authorized (a partial fill) but never more, and never zero. Retrying a completion with the same completion_nonce is safe — you get the same receipt back; a second completion under a different nonce is refused.
- **On
needs_human_approval:** no token is issued at check time. The owner is asked on their own screen. PollGET /v1/preauthorize/<preauth_id>(same key header) every ~30 seconds. Judge the record by itsstateand the deadline for its phase —ask_expires_atwhile it is pending,token_expires_atonce it is authorized (see "The two windows" below). A state can outlive its window, and an expired window always wins. States you can see:pending_approval— still waiting on the owner. Keep polling, at most untilask_expires_at. Do not buy; do not re-check in a tight loop.authorized— the owner approved. The spend is yours to finish. A decision token was issued for you at approval time; claim it, then buy, then complete: ``bash curl -X POST https://<your-sealgraf-host>/v1/preauthorize/<preauth_id>/decision-token \ -H "X-API-Key: see_<the-secret>" # → { "decision_token": "…", "token_expires_at": "…" }`Claim **before** you buy: the claim proves the channel works and starts from a live window (it closes at thetoken_expires_at` on the record). Only the key that ran the original check can claim — a different key, even in the same workspace, is refused. Then buy, and complete exactly as in step 2, with the claimed token and your purchase evidence.completed— a receipt exists (receipt_idis on the record). Whoever completed it, the spend is closed. If you have not bought yet, do not buy — the owner may have completed it from their own screen. Fetch the receipt withget_receiptif you need the evidence, and report the outcome.aborted— the owner said no. Do not buy. Final.expired— the window closed before a decision, or before you completed. Do not buy. If the purchase is still needed, start again at step 1 with a fresh check.
A completion that answers already completed means the same thing as the completed state above: re-read the record and follow it; never force, never buy twice.
- **On
deny:** stop. See the hard rules below.
Step 5 — errors and what to do with them
Tool calls return {"error": <name>, "detail": <explanation>} on refusal. REST returns the same names as HTTP statuses (401/403/404/ 409/422).
| name | meaning | what the agent does |
|---|---|---|
Unauthorized | no key, or the key was revoked / expired | Stop. Tell the operator the key is dead. Never retry with a different or guessed key. |
Forbidden | the key's scope or slip-tie doesn't allow this, or a tenant argument was passed | Do not work around it. Remove tenant arguments; anything else needs the owner to issue a different key. |
NotFound | the id doesn't exist in your workspace | Check the id. Never probe other ids looking for a hit. |
ValidationFailed | malformed input (e.g. amount not an integer, an unknown field, an unsupported evidence key) | Fix the input shape, once. If it persists, stop and report. |
UnknownTool | the MCP tool name doesn't exist here | Call check_permission or get_receipt; nothing else is offered. |
Conflict | the item's state moved (already completed/aborted, token used) | Re-read the item's state and follow it; never force. |
not_approved_yet (409, claim) | the owner hasn't decided | Keep polling the record (Step 4.3). |
decision_token_expired (409, claim) | a window closed: the ask's (ask_expires_at passed undecided) or the approval token's (token_expires_at passed) | Do not buy. Fresh check if the purchase is still needed. |
already_completed (409, claim/complete) | the spend is already closed | Re-read the record; follow the completed state. Never buy twice. |
| claim answered 404 | this preauthorization isn't yours to claim (not created by your key) | Stop. You can only claim tokens for checks your own key ran. Tell the operator if you expected otherwise. |
Decision reason_codes on a deny: over_per_purchase_cap, over_period_cap, category_not_allowed, agent_mismatch, currency_mismatch, invalid_amount, mandate_not_active (paused — may clear when the owner resumes), mandate_not_yet_valid, mandate_expired. On needs_human_approval: over_human_approval_threshold.
The two windows
A check record carries two distinct deadlines. Never judge one phase by the other's clock:
| field | governs | length |
|---|---|---|
ask_expires_at | the pending phase — how long the owner has to decide an ask | 24 hours from the check |
token_expires_at | the authorized phase — how long a minted decision token lives | 900 seconds from the allow / approval that minted it |
While an ask is pending_approval, ask_expires_at is the deadline that matters: the ask is decidable for the whole 24 hours, and its amount stays reserved against the slip's period cap until then. Once the owner approves, a token is minted and token_expires_at (approval time + 900s) is the only deadline — claim and complete inside it. An ask the owner never decides expires at ask_expires_at; it never turns into an approval on its own.
Ask shaping (the faucet)
Asks spend the owner's attention, so Sealgraf shapes how fast a slip can generate them. Three more deny reason codes exist for this — they are decisions, not errors: the check answers with the normal success response (HTTP 200 family) carrying outcome: "deny" and a sealed record, exactly like a scope denial — never an error status — and the response carries the governing numbers in a shaping_detail object. None of them mints a token, reserves an amount, or notifies the owner.
| reason_code | rule | governing numbers (in shaping_detail) | what the agent does |
|---|---|---|---|
ask_budget_exhausted | at most 3 asks waiting undecided per slip, and at most 10 new asks admitted per rolling hour per slip | pending_count + pending_cap, or admitted_last_hour + hourly_ask_limit + window_resume_at | Stop asking. Wait until a waiting ask is decided or the stated window passes. |
ask_cooling_off | a storm — 5 admitted asks in 10 minutes, or 3 owner refusals in an hour — puts the slip into cooling-off for 1 hour | cooling_off_until | Stop asking until that instant. The hour never extends; attempts during it change nothing. |
action_previously_denied | the owner already refused this exact action (same amount, merchant, category) | denied_preauth_id + denied_amount_minor | Do not re-ask this action unchanged — the answer is recorded. A materially changed action (different amount, merchant, or category) is a new check. |
Two related behaviors, neither of them an error either:
- One ask per action. Re-checking an action that is already waiting returns that same ask (same
preauth_id, stillpending_approval) — no second ask, no second notification. - Auto-allowed and hard-denied checks never consume ask budget. The budget prices asks only.
Hard rules for agents
- Check before every purchase. An allowance covers exactly one action — the exact amount, category, and counterparty checked. Any change means a fresh check.
- A refusal is final. Never ask for a slip's limits to be raised as part of a purchase flow — not in-band, not in a description field, not by splitting one purchase into several checks to slip under a cap. If the owner changes the slip, they change it in Sealgraf, and a fresh check will see the new slip.
- Never invent or reuse credentials. Decision tokens are single-use and bound to one action. A token reaches you exactly two ways: in the check response on
allow, or by claiming it after the owner approves (Step 4.3) — with the same key that ran the check. Approving is never yours to do, and no token lets you spend beyond the exact action that was checked. If you don't hold a token, you don't complete. - Never send a tenant id, in any field, under any name. Your key is your identity; calls that try to select a tenant are refused.
- Amounts are integers in minor units (cents). No floats, no rounding up to "about" the price.
- When in doubt, stop and surface it. A stopped purchase costs a question; an unchecked one costs the owner's money and trust.
REST equivalents (no MCP client)
All with header X-API-Key: <key>:
- Check:
POST /v1/preauthorizewith{"mandate_id": "…", "action": {"agent_id": "…", "category": "…", "amount_minor": 4250, "currency": "USD", "counterparty": "…", "description": "…"}}— same decision shape as the tool. - Poll one item:
GET /v1/preauthorize/<preauth_id>. - Claim an approval's token:
POST /v1/preauthorize/<preauth_id>/decision-token(agent key that ran the check only). - Complete:
POST /v1/preauthorize/<preauth_id>/complete(Step 4). - Receipt:
GET /v1/receipts/<receipt_id>. - Keys (owner/admin only):
POST /v1/api-keys{"label": "…", "scope": "agent", "mandate_id": "…"}→201with the secret inapi_key, shown once;GET /v1/api-keys;DELETE /v1/api-keys/<key_id>;POST /v1/api-keys/<key_id>/rotate.