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.
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 under their account. From a repo checkout the CLI wraps it:
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 — a rotation issues a new keypair and old signatures stop verifying, 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):
{
"mandate_id": "mnd_tr7…",
"action": "account.refill",
"amount": 50000,
"currency": "USD",
"platform_id": "plt_northbank",
"nonce": "3f9a…",
"ts": "2026-07-22T18:04:11Z"
}
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.