Give your agent a passport
Hold a keypair, sign a short assertion on every request, and present it as an X-Passport header. Three ways to do it: the SDK, the MCP server for Claude Code, or the CLI.
Keys & the model #
An agent (agt_…) is an Ed25519 keypair plus self-declared runtime metadata — name, model, framework. The private key stays in the agent runtime; only the public key is registered with the passport. A principal then issues your agent a mandate that scopes exactly what it may do.
The agent signs each request locally. The platform and the passport only ever see a signature over a small, single-use payload — never your private key, and never the raw mandate.
Onboarding a principal, registering an agent, and issuing or revoking mandates cost nothing, with no cap and no expiry. Principals are never metered and never billed — there is no plan to be on. Only platforms, the ones calling /v1/verify on their own routes, are metered: 1,000 verifications a month free, then $99/month for 100,000.
Agent SDK #
The SDK holds the keypair, signs assertions, and attaches headers.
npm i @writhq/sdk
import { PassportAgent } from "@writhq/sdk";
// loads (or creates) the local Ed25519 keystore — PASSPORT_AGENT_HOME
const agent = await PassportAgent.load({
passportUrl: "https://api.writhq.com",
name: "treasury-bot", runtime: "claude-code",
});
// one-time: principal registers agent.publicJwk → agt_…, then pin context
agent.setAgentId("agt_9f…");
agent.setContext({ mandate: "mnd_tr7…", platform: "plt_northbank" });
// every call after: signs the assertion & presents X-Passport for you
const res = await agent.present("https://northbank-production.up.railway.app/api/refill", {
action: "account.refill",
amount: 50000, currency: "USD", // minor units
});
// res.status === 200 · res.body carries the decision, chain, and receipt
Register & rotate #
Register the public key once; rotate it any time without re-issuing mandates. Registration is principal-authorized — the principal registers your public key, either from the principal console at /principal or by API. The console can generate the Ed25519 keypair for you right in the browser (the private key is shown once and never sent to Writ), or you can register an agent you already keyed elsewhere by pasting in the public JWK from passport-agent init. From a repo checkout the CLI wraps the API path:
export PASSPORT_AGENT_HOME="$PWD/.passport-agent" # pin the keystore
npm run cli -w @passport/agent -- init
# generates the local keypair, prints the public JWK
npm run cli -w @passport/agent -- register --principal-secret prn_sk_…
# principal registers the public key → agt_9f…
Under the hood these are POST /v1/agents (principal-authorized) and POST /v1/agents/{id}/rotate {public_jwk} — you generate the new keypair, the passport only ever replaces the public key on file. Assertions signed with the old key stop verifying the moment it lands, but mandates stay attached to the agent id.
What gets signed #
Each request signs this exact payload with the agent key, as a JWS, and sends it in the X-Passport header. Nonce + timestamp make it single-use (see replay rules):
{
"agent": "agt_9f…",
"mandate": "mnd_tr7…",
"action": "account.refill",
"amount": 50000,
"currency": "USD",
"platform": "plt_northbank",
"nonce": "3f9ac1d84e2b7a06",
"iat": 1786500251
}
amount is in minor units. iat is unix seconds — an integer, not an ISO string — and doubles as the replay timestamp. A document.* assertion carries one more claim, document; see Signing documents.
Signing documents #
The same keypair answers a second question: may this agent put its principal's name on this document? buildSignAssertion writes the assertion; everything after it — the X-Passport header, the verify round trip, the receipt — is identical to a payment.
import { buildSignAssertion, hashDocument } from "@writhq/sdk";
const assertion = await buildSignAssertion(
{
agent: "agt_9f…",
mandate: "mnd_tr7…",
platform: "plt_northbank",
document_hash: await hashDocument(pdfBytes),
document_class: "nda",
counterparty: "Northbank Sandbox",
liability_minor: 2_500_000, // $25,000 of exposure
},
agentPrivateKey
);
// present it as the X-Passport header, exactly like a payment assertion
hashDocument returns the lowercase hex SHA-256 of the document bytes. That digest is what the assertion commits to and what the receipt records — the passport never receives the document itself.
Check before you sign, the same way you check before you spend. Read the mandate's document_classes and the liability remaining this period first, then decide. Paper of a class the mandate doesn't list denies document_class; a document worth more than the per-document cap denies per_tx_cap; too much cumulative exposure denies period_cap.
Writ attests that the authority existed. It never produces the signature — the e-signature platform still does that — and none of this is a qualified electronic signature (eIDAS/QES). Run it end to end with npx @writhq/demo sign (@writhq/demo 0.2.0, published).
MCP server (Claude Code) #
@writhq/mcp is an MCP server so Claude Code — or any MCP-capable runtime — can transact with a passport today, no glue code. Register it in your MCP config (e.g. ~/.claude/mcp.json, or a project .mcp.json):
{
"mcpServers": {
"writ": {
"command": "npx",
"args": ["-y", "@writhq/mcp"],
"env": {
"PASSPORT_URL": "https://api.writhq.com",
"PASSPORT_AGENT_HOME": "/path/to/.passport-agent"
}
}
}
}
The server holds the agent's local keystore (PASSPORT_AGENT_HOME); register the public key and set the mandate/platform context once (SDK or CLI, above) and every MCP client that launches it shares the identity.
MCP tools #
The server exposes three tools the model can call directly:
| Tool | Args | What it does |
|---|---|---|
| passport_present | action, amount, currency, url? | Signs the assertion, attaches X-Passport, calls the platform (or returns the header), and reports the decision + resolved chain. Amount in minor units. |
| passport_status | — | Returns the agent's identity and mandate context: registered agt_ id, configured mandate/platform, and whether the passport is reachable — so the model knows what it may do before trying. |
| passport_refill_demo | amount | Refills the Northbank Sandbox with a signed assertion (amount in dollars). Demonstrates ALLOW and the per-tx / period / revoked DENYs end-to-end. |
passport_present({ action: "account.refill", amount: 50000, currency: "USD" })
// → {
// decision: "allow", reason: "ok",
// chain: { principal, agent, mandate: { remaining_this_period: 150000 } },
// verification_id: "vrf_9c1…"
// }
Have the model call passport_status first to read remaining_this_period, then size the transaction to fit the mandate. It turns a blind per_tx_cap / period_cap deny into a decision the agent makes on purpose.
CLI reference #
From a repo checkout: npm run cli -w @passport/agent -- <command> (set PASSPORT_AGENT_HOME so every call shares one keystore).
| Command | Description |
|---|---|
| init | Generate the local Ed25519 keypair and print the public JWK. |
| register --principal-secret prn_sk_… | Register the public key under a principal → agt_…. |
| use --mandate mnd_… --platform plt_… | Pin the mandate + platform context for presents. |
| status | Show the agent id, mandate context, and passport reachability. |
| refill --amount 500 | Sign an assertion and present it to the Northbank refill endpoint (amount in dollars). |
The MCP server itself is npx @writhq/mcp — see above.