Skip to content

The TypeScript SDK ​

The receipt issuer inside the agent's own process, for tools that are plain functions rather than MCP servers. Every field it records is claimed: the SDK is the agent's own report, not evidence against the agent. Use it to record everything; use the gateway to enforce the calls that matter.

ts
import { createSdkIssuer, loadSdkConfig } from "@agent-custody/receipts";
const issuer = createSdkIssuer(loadSdkConfig("sdk.json"));

The config file ​

sdk.json, paths relative to the file.

json
{
  "agentId": "support-agent",
  "principalId": "user_456",
  "identity": { "keyFile": "keys/app.key" },
  "policyFile": "policy.cedar",
  "receiptsDir": "receipts",
  "log": { "url": "https://log.agent-custody.dev/t/acme/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN", "hashOnly": true },
  "framework": "openai-agents",
  "otel": { "url": "http://localhost:4318" }
}
fieldmeaning
agentIdrecorded on every receipt as the agent, claimed
principalIdoptional, recorded as the principal, claimed
identity.keyFilethe application's Ed25519 key; signs receipts
policyFileoptional Cedar policy; with it, decide and wrap can deny
modeoptional "enforce" (default) or "observe": in observe mode decide returns the decision with enforced: false, wrap runs the function on a deny and records it, and every hook adapter returns no block
receiptsDirwhere bundles are written
logFile or logexactly one: a local Merkle log, or a remote one as in the gateway config
frameworkoptional free-text label, recorded on issuer.framework
otel, splunkoptional exporters
capture, captureKeyEnvoptional private evidence, as for the gateway: what the receipt file carries of each tool's arguments and result, and the environment variable holding the key that encrypts withheld fields

SdkIssuer ​

ts
interface SdkIssuer {
  agentId: string;
  keyid: string;
  log: LogSink;                                             // { kind: "file" | "http", where, append(leaf) }
  ruleReasons: readonly (string | null)[];                  // the @reason of each policy in the file, in order, or null
  decide(ev: ToolEvent): PolicyDecision | null;
  record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): Promise<ReceiptBundle>;
  wrap<A, R>(tool: string, fn: (args: A) => R | Promise<R>, meta?: { model?, session? }): (args: A) => Promise<R>;
}
interface ToolEvent { tool: string; args: Record<string, unknown>; model?: string | null; session?: { id?: string | null; toolUseId?: string | null } }
type Outcome = { status: "executed" | "failed"; result: unknown } | { status: "denied"; reason: string } | { status: "error"; error: string };
interface PolicyDecision { decision: "allow" | "deny"; reasons: string[]; errors: string[]; policyDigest: string }

decide(event) ​

Evaluates the policy for a call, records nothing. null when no policy is configured.

ts
issuer.decide({ tool: "stripe.refund", args: { customer_id: "cust_123", amount: 5_000_000 } });
// → { decision: "deny", reasons: [], errors: [], policyDigest: "0e56…" }

Over the SDK the Cedar context has args and facts: {}; there is no gateway to fetch facts and no signed grant to supply context.grant.

record(event, outcome, policy?) ​

Issues one receipt: signs it, appends its hash to the log, writes receipts/<id>.json, tells the exporters. Rejects if the log refuses; then no bundle is written.

ts
const bundle = await issuer.record({ tool: "crm.lookup", args: { id: 1 } }, { status: "executed", result: { plan: "pro" } }, issuer.decide(ev));
bundle.inclusion.leafIndex;                                    // 0
receiptIdOf(bundle);                                           // "c92e582b-…"

The bundle is the same receipt bundle the gateway writes, with issuer.kind: "sdk", principal.provenance: "claimed", and no delegation.

wrap(tool, fn, meta?) ​

Decide, run, record. Throws PolicyDeniedError (.tool, .reason, .receiptId) on deny, after issuing the denial receipt; a thrown fn is recorded as error and re-thrown.

ts
const refund = issuer.wrap("stripe.refund", async (args: { customer_id: string; amount: number }) => stripe.refunds.create(args));
await refund({ customer_id: "cust_123", amount: 2500 });     // executed, recorded

Simulating a policy ​

Exported from @agent-custody/receipts:

ts
import { simulatePolicy, formatSimulation } from "@agent-custody/receipts";
// predicates are the recorded statements from bundles you have verified with verifyBundle.
const report = simulatePolicy(proposedCedar, predicates);
console.log(formatSimulation(report));

simulatePolicy(policyText: string, receipts: Iterable<ReceiptPredicate>, options?: SimulationOptions): SimulationReport is a pure replay of the recorded policy inputs: args, each fact's value, and, for gateway receipts, grant: { principal, scopes } from the embedded delegation. It neither verifies signatures nor executes tools; use verifyBundle first, or the CLI. It compares policy.decision even in observe mode, rather than the execution status.

report fieldmeaning
runId, timestampa UUID for this run and its ISO timestamp
policyDigest, previousPolicyDigestssha256 of the proposed text and the sorted distinct original digests
total, evaluated, unchangedsupplied calls, replayed calls, and calls whose decision stayed the same
reconciliationsreconciliation receipts: provider reads with no policy decision. A replay counts them here, not in evaluated or skipped, and they do not make the report incomplete, unless the original receipt of the call is missing: then the call is in skipped. Call verifyBundle first: the replay trusts a reconciliation by its shape and its link only
changes{ receiptId, tool, previous, proposed }[]: both decisions for every changed call
groups{ tool, reason, receiptIds }[]: changed calls grouped by the proposed reasons or errors
skipped{ receiptId, tool, reason }[]: unavailable inputs, missing baseline, or calls stopped before policy evaluation
completefalse when any receipt was skipped

formatSimulation(report): string prints the digests, grouped receipt ids and decision changes, counts, and any incomplete calls. options takes captureKey (a 32-byte Buffer) to decrypt withheld inputs, disclosures (a Map from receipt id to a disclosure file's contents), and unreplayable (SimulationSkip[]: calls that have no receipt; each counts in total and skipped). The function reads receipts once, so a generator that verifies one bundle at a time keeps memory flat. Types: SimulationReport, SimulationOptions, SimulationChange, SimulationSkip, SimulationGroup. Withheld arguments or fact values are reported as incomplete; withheld results do not affect replay. Grant restrictions cannot be widened by Cedar. See the policy guide and tutorial 28.

Adapters ​

Each records the framework's tool calls through the issuer. Enforce or observe, never both on one tool.

importfunctionwhat it does
@agent-custody/receipts/sdk/vercel-aiwrapTools(issuer, tools)returns the same tool map with each execute wrapped: decide, run, record; a denial returns the denial text as the tool result
@agent-custody/receipts/sdk/openai-agentswrapTools(issuer, tools)the same for OpenAI Agents SDK function tools (invoke wrapped)
@agent-custody/receipts/sdk/openai-agentsobserveRunner(issuer, runner)record-only: listens to a runner's tool events; evaluates no policy
@agent-custody/receipts/sdk/langchainreceiptCallbacks(issuer) → { callbacks }record-only callback handler for LangChain and LangGraph; pass it in the run config
@agent-custody/receipts/sdk/claudehandleHookEvent(issuer, input)Claude Code and Claude Agent SDK hooks; see Claude Code
@agent-custody/receipts/sdk/claudeclaudeAgentHooks(issuer, matcher?)the in-process hooks object for the Claude Agent SDK, built on handleHookEvent
@agent-custody/receipts/sdk/openclawopenclawHooks(issuer) → { before_tool_call, after_tool_call }OpenClaw plugin hooks: before_tool_call(event, ctx) returns { block: true, blockReason } on a policy deny after issuing the denial receipt, nothing otherwise; after_tool_call(event, ctx) records the executed or failed call. event is OpenClaw's { toolName, params, toolCallId?, result?, error? }, ctx its { sessionId?, sessionKey? }
@agent-custody/receipts/sdk/openclawregisterOpenClaw(api, issuer)calls api.on for both hooks, for a plugin's register(api)
@agent-custody/receipts/sdk/deepseek-harnessthe module itself: name, inject, Config, apply(ctx, { config? })a DeepSeek Harness plugin, listed by this path with the SDK config file as its setting (or AGENT_CUSTODY_CONFIG); registers tools/pre-execute and tools/post-execute
@agent-custody/receipts/sdk/deepseek-harnessdeepseekHarnessHooks(issuer) → { preExecute, postExecute }preExecute(exec, next) returns { kind: "deny", reason } on an enforced policy deny after issuing the denial receipt, else next(); postExecute(exec, result, next) records the executed (content/value) or failed (error) call, then next(). exec is the harness's { name, arguments, callId, agent }
@agent-custody/receipts/sdk/deepseek-harnessregisterDeepSeekHarness(ctx, issuer)calls ctx.on for both events

When an enforcing adapter cannot issue the denial receipt, the call stays denied and the reason ends with (no receipt: <why>) in place of (receipt <id>). noReceipt(error), exported from the package root, builds that text: no receipt: the receipt could not be recorded. The reason reaches the model, and the error can carry the log's answer, so noReceipt writes the error to stderr and keeps it out of the reason. A log error shows the log URL without its user, password, or query string. handleHookEvent, OpenAI Agents wrapTools, openclawHooks, and deepseekHarnessHooks use it. The Vercel AI wrapTools and issuer.wrap throw PolicyDeniedError, which needs a receipt id, so a failed denial receipt there still rejects with the log's error.

Agents over A2A ​

Exported from @agent-custody/receipts beside the gateway. They belong to the gateway, not the in-process issuer, and are listed here with the other exports; the behaviour is on the gateway page.

exportsignaturewhat it does
a2aUpstream(name, cfg: A2aUpstreamConfig, { fetch?, env? }?) → upstream client with resolved(): Promise<ResolvedCard>a remote agent as an upstream: <prefix>.send delegates a task, <prefix>.card reads its card
fetchAgentCard(url, headers, fetch, timeoutMs): Promise<ResolvedCard>fetches the card from a base URL or a card URL; ResolvedCard is { card: AgentCard, cardUrl, endpoint, legacy }, legacy true for a 0.3 card
serveA2a(host: GatewayHost, cfg: A2aUpstreamConfig, { port, host?, grant?, log? }: A2aServerOptions): Promise<RunningA2aServer>the gateway as an A2A agent; RunningA2aServer is { url, close() }
textOf(message) => stringa message's text parts joined, what a policy reads as context.args.text
A2A_CARD_PATH, A2A_RECEIPT_METADATA_KEY, A2A_STREAM_META_KEY"/.well-known/agent-card.json", "agent-custody/receipt", "agent-custody/a2a-stream"the card path, the metadata key an answered task or message names its receipt under, and the _meta key a .send result carries a relayed stream's events under

Types: AgentCard, ResolvedCard, A2aServerOptions, RunningA2aServer.

Human approvals ​

Exported from @agent-custody/receipts for signing and verifying exact actions at the gateway. The SDK issuer does not enforce these gateway rules.

ts
interface ApprovalBinding {
  principal: string;
  agent: string;
  tool: string;
  args: Record<string, unknown>;
  fields?: { amount?: string; currency?: string; beneficiary?: string };
}
type ApprovalVerifyResult =
  | { ok: true; approval: Approval; approverKeyids: string[] }
  | { ok: false; error: string };
exportsignaturebehaviour
createApproval(key: KeyPair, input: ApprovalBinding & { approvalId?: string; issuedAt?: string; expiresAt: string }): Envelopesigns a v0.1 approval; defaults to a UUID id and current issuance time; requires expiry after issuance. The digest covers every JSON argument. Present action fields are copied by the configured top-level names; defaults are amount, currency, beneficiary. Amount is a safe integer in minor units; the other two are nonempty strings
verifyApproval(envelope: unknown, trusted: PublicKeyRef[], binding: ApprovalBinding, { at?: string, minApprovers?: 1 | 2, maxLifetimeSeconds?: number }?): ApprovalVerifyResultchecks the envelope/schema, distinct trusted signatures, complete binding, the lifetime cap when given (expiresAt minus issuedAt), and half-open validity window [issuedAt, expiresAt); current time, one approver, and no cap by default. Returns a failure for untrusted input; does not consume
verifyApprovalStatement(envelope: unknown, trusted: PublicKeyRef[], minApprovers: 1 | 2 = 1): ApprovalVerifyResultchecks only the schema and distinct trusted signatures. Does not establish action binding or time validity; use verifyApproval at an enforcement boundary
consumeApproval(usedDir: string, approvalId: string, receiptId: string, at: string): voidexclusively creates a persistent consumption marker before a side effect; throws on replay or storage failure; does not undo a marker after later failures
ApprovalSchemaZod schemastrict payload schema for Approval, with version: "0.1", UUID approvalId, principal, agent, tool, argsDigest, optional action fields, issuedAt, expiresAt
APPROVAL_TYPE, APPROVAL_META_KEY"application/vnd.agent-custody.approval+json", "agent-custody/approval"signed payload type and caller _meta key

Types: Approval, ApprovalBinding, ApprovalRecord, ApprovalVerifyResult. ApprovalRecord is the gateway's observed check, embedded in receipts and pre-commit authorizations; it carries the envelope when its statement has a trusted signature and valid schema, threshold, optional field mapping, times, status, optional reason and signer keyids.

For two approvers, dsseCountersign(createApproval(firstKey, action), secondKey) adds a signature to the same payload. Distinct trusted keyids count once each; distinct human identities are an operational assumption. Keep private keys with the people reviewing the complete action. Tutorial 28 runs the gateway, refusals, two signatures, consumption and offline verification.

Private evidence ​

Exported from @agent-custody/receipts. createIssuer(key, receiptsDir, log, { capture: { rules, key } }) applies the rule to every statement it signs (issue(predicate, ctx?, retainPrepared?), authorize(predicate, ctx?)), which is how the gateway and createSdkIssuer get it, and the verifier uses restorePaths to put the values back; the behaviour is on the gateway and verify pages.

The optional synchronous retainPrepared(candidate): PreparedReceipt callback durably chooses the signed view before a log append. PreparedReceipt contains { envelope, view: { predicate, salts } }; it is private recovery data, including commitment salts, and must never be exported or handed to a verifier. Returning the stored winner lets restarted/concurrent reconciliation attempts append identical bytes and retain the same salts. The issuer checks its signature, statement and receipt id before appending. Ordinary callers omit this callback.

exportsignaturewhat it does
privateView(predicate, rule: CaptureRule | undefined, key?: Buffer, ctx?: CaptureContext) => { predicate, salts }the statement a receipt carries under a rule: every non-structural value withheld as the rule says, the digests salted from the field salts, the capture note set; and the salts by pathKey, which the issuer keeps in receipts/salts/<id>.json and never signs. The predicate itself and no salts under a clear rule. ctx.clearFactArgs names the fact arguments that may stay in clear, ctx.factResults the lookup results behind the fact digests
restorePaths(predicate, note: CaptureNote | undefined, disclosed?, key?: Buffer) => Restorationthe statement with every restorable path put back: { predicate, restored: Map<pathKey, "disclosed" | "decrypted">, salts: Map<pathKey, string>, missing: Path[], problems: { path, error }[] }; a path the note names that holds no commitment, a disclosed value without its salt, or a disclosure that contradicts a commitment, is a problem, never a throw. digestSaltFor(restoration, note, prefix) is the salt of the digest over a part once its values are back
withhold(value, key?: Buffer) => { commitment, salt }a commitment over a random salt and the canonical value, and the salt, which the receipt never carries; with a key, the value and the salt under AES-256-GCM inside the commitment
checkCommitment(c: Commitment, value, salt) => booleantrue when value under salt is what the commitment was made over
reveal(c: Commitment, key: Buffer) => { value, salt }the value and the salt behind an encrypted commitment, checked against it; throws for a withheld commitment or a key that is not the one it was encrypted under
isCommitment, isDisclosed, digestSalt(v) => boolean, (v) => boolean, (salts: string[]) => string | undefinedwhether a value is a well-formed commitment; whether it is a disclosed value { $custody: "disclosed", salt, value? }; the salt of a digest over values with these salts, none when there are none
ruleMode, ruleFor(rule) => CaptureMode, (capture: CaptureConfig | undefined, tool) => CaptureRule | undefinedthe one mode a rule withholds in (encrypted when any side is, digest when any side is private, else clear); the tool's rule, or the "*" rule
REST"$rest"the slot in a captured side's view that holds the commitment over every field not kept in clear
getAt, setAt, pathKey, pathText, under(root, path: Path), (root, path, value) => root', (path) => string, (path) => string, (path, prefix) => booleanown-property reads and copying writes at a path (a field named __proto__ stays a field); a path as a map key, as text, and whether it lies under a prefix
captureKeyFrom, captureKeyId(text) => Buffer, (key) => stringthe 32-byte key from its hex or base64 form; the sha256 of a key, which an encrypted commitment carries as keyid

Types: CaptureMode, CaptureSide, CaptureRule, CaptureConfig, CaptureNote ({ mode, withheld: Path[] }), Path, CaptureContext, PrivateView, Restoration, How, Withheld, Encrypted, Commitment, Disclosed.

The log client ​

httpLog(url, { token?, hashOnly?, retries? = 3, timeoutMs? = 10000, fetch? }) is what the gateway and SDK use for a remote log: POST <url>append with { leaf } or { leafHash }, retrying 429 and 5xx with backoff and honouring Retry-After. fileLog(path, key) is the local one. Both are LogSinks: append(leaf): Promise<{ inclusion, treeHead }>. openLog(config, key) picks from a config.

Verifying in code ​

ts
import { verifyBundle, formatReport, loadPublicKey } from "@agent-custody/receipts";
const r = verifyBundle(bundle, { issuerKeys: [loadPublicKey("keys/app.pub")], principalKeys: [] });
r.ok; r.checks;                                                // [{ name, ok, detail? }]
console.log(formatReport(r));

Every option and check is on the verify page.

Keys ​

generateKeyPair(): KeyPair, writeKeyPair(kp, dir, name) → { keyFile, pubFile }, loadPrivateKey(path), loadPublicKey(path): PublicKeyRef ({ publicKey, keyid }), publicKeyFromPem(pem), publicKeyToPem(publicKey). Keys are Ed25519; a keyid is the SHA-256 of the SPKI DER. dsseSign(payloadType, payload, keyPair): Envelope, dsseCountersign(envelope, keyPair): Envelope, and dsseVerify(envelope, trustedKeys) are the primitives every signed artefact uses.

Design tokens ​

DESIGN_CSS is a string of CSS: the colour, type, spacing, and radius tokens of agent-custody, light and dark, and the shared button, select, card, dialog, and heading styles. The portal, the log admin, the gateway console, and the review page of @agent-custody/state put it in their own <style> element, so a page with default-src 'none' makes no outside request. Dark mode follows the system, or data-theme="dark" or "light" on <html>.

tokenuse
--bg, --panel, --softpage background, cards and panels, hover fill
--line, --line-strongdividers, control borders
--ink, --ink2, --ink3text, secondary text, quiet text (all 4.5:1 or more on --panel)
--accent, --accent-hover, --accent-active, --accent-ink, --accent-bgthe amber action colour, its states, the text on it, a soft fill
--ok, --warn, --bad and --ok-bg, --warn-bg, --bad-bgstatus text and fills
--font, --monoInter where the machine has it, else the system font; monospace for code and ids
--text-xs to --text-3xl12, 14, 16, 18, 24, 32, 44 px
--space-1 to --space-84, 8, 12, 16, 24, 32, 48, 64 px
--radius-sm, --radius-md, --radius-fullcontrols, cards and dialogs, pills