Onboard your first API
Take one endpoint from "blocks agent traffic" to "verifies it" — a guarded route, a test agent making signed calls, caps enforced at the boundary, and an audit log of every decision. Budget about 30 minutes.
Pick one route to start — the one where agent traffic already shows up and gets rejected. The refill/deposit primitive is the sweet spot: account top-ups, API-credit refills, ad-budget top-ups, order placement. Nothing here moves real money: KYC is a sandbox stub and the sanctions screen is a stub.
Step 0 — See the end state #
2 minutes, nothing to install. Northbank is a live demo brokerage whose /api/refill is guarded by the exact middleware you're about to add. Hit it anonymously and watch it fail closed:
curl -i -X POST https://northbank-production.up.railway.app/api/refill \
-d '{"amount_minor":50000}'
# → HTTP/2 403
# {"error":"KYA required","reason":"missing_passport",
# "onboarding_url":"https://api.writhq.com/onboarding"}
That 403 — with an onboarding link instead of a dead end — is what your unguarded route is missing today.
Step 1 — Get your platform record and API key #
5 minutes. Your platform is a plt_ record on the passport plus an API key that authenticates your server-to-server calls to /v1/verify.
Against the hosted alpha: platform registration is operator-gated — reach out and we provision plt_yourname + key same-day.
Self-serve, locally: run the whole stack on your machine and provision yourself:
git clone https://github.com/Zilula/writ && cd writ && npm install
npm run dev # passport :3000 · northbank :3100
# register your platform (dev admin token; override with PASSPORT_ADMIN_TOKEN)
curl -s -X POST http://localhost:3000/v1/platforms \
-H "authorization: Bearer dev_admin_token" \
-H "content-type: application/json" \
-d '{"name":"My Platform"}'
# → {"id":"plt_…","api_key":"plt_sk_plt_…","created":true}
Keep the key in your secret store — the passport keeps only a scrypt hash at rest.
Step 2 — Guard the route #
10 minutes. Install the middleware and set two env vars in your service:
npm i @writhq/verify
# env
PASSPORT_URL=http://localhost:3000 # or the hosted passport URL
PLATFORM_API_KEY=plt_sk_…
Then wrap the route. Hono shown; requireKYAExpress is the Express twin, and evaluateRequest / KYAClient cover everything else:
import { requireKYA } from "@writhq/verify";
app.post(
"/api/refill",
requireKYA({
action: "account.refill",
// bind the REAL amount (minor units) — caps are enforced against this
amount: async (c) => {
const body = await c.req.json().catch(() => ({}));
return Number(body.amount_minor ?? 0);
},
}),
async (c) => {
const kya = c.get("kya"); // decision, chain, verification_id, receipt
await accounts.refill(/* … */); // your existing logic, unchanged
return c.json({ ok: true, principal: kya.chain.principal });
}
);
The middleware fails closed: missing header, deny, or an unreachable passport all block the request before your handler runs.
Step 3 — Confirm the block #
2 minutes.
curl -i -X POST http://localhost:4000/api/refill -d '{"amount_minor":50000}'
# → 403 {"error":"KYA required","reason":"missing_passport","onboarding_url":"…"}
Set onboardingUrl in the middleware options to your co-branded flow when you have one — the funnel is anonymous → 403 → onboard → mandate → verified buyer.
Step 4 — Run the full chain with a test agent #
8 minutes. Provision a principal, agent, and mandate, then present signed assertions at your route. From the repo checkout:
export PASSPORT_AGENT_HOME="$PWD/.passport-agent" # one keystore for every call
# principal (sandbox KYC → verified) + agent + mandate in one shot:
npm run seed
Point the agent at your platform (the seeded default is Northbank). The mandate's scope must name your plt_ id, and the refill command presents to {--url}/api/refill:
npm run cli -w @passport/agent -- use --mandate mnd_… --platform plt_…
npm run cli -w @passport/agent -- refill --amount 500 --url http://localhost:4000 # → allow
npm run cli -w @passport/agent -- refill --amount 2000 --url http://localhost:4000 # → deny · per_tx_cap
If your route isn't /api/refill, present from code instead — @writhq/sdk's PassportAgent.load(…) then agent.present(url, { action, amount }) hits any endpoint — or drive it from Claude Code with npx @writhq/mcp. Then revoke the mandate (POST /v1/mandates/{id}/revoke, or one click in the principal dashboard) and watch the next present deny with mandate_revoked within seconds — revocation is a live check, not a cached one.
You have seen allow, per_tx_cap, and mandate_revoked at your own route, and each response carried the resolved chain + a signed receipt.
Step 5 — Countersign and audit #
3 minutes. After your handler executes the action, countersign the verification — a record signed by both sides is nearly incontestable:
{ "outcome": "executed", "platform_ref": "txn_…", "platform_sig": "<JWS by your platform key>" }
Export the decision log any time for reconciliation or compliance review — every decision is an immutable, signed vrf_ record:
The platform dashboard (/platform on the passport) renders the same log with co-signature status and one-click JSON export.
Step 6 — Production checklist #
- Caps make sense. Per-tx and period caps agreed with your principals — start narrow; widening a mandate is easy, clawing back is not.
- Amount binding is real. The
amountresolver reads what your ledger will actually move, not a client-declared field elsewhere in the body. - Fail-closed verified. Kill
PASSPORT_URLin staging and confirm the route blocks instead of admitting. - Receipts verified. Check the receipt JWS against the issuer JWKS:
GET /v1/issuer/jwks(Ed25519). - Countersigning wired into the post-execute path, not best-effort.
- Deny monitoring. Alert on spikes of
replay/unknown_agent(probing) and onper_tx_capbursts (an agent mis-sized for its mandate). - Onboarding URL points somewhere real, so blocked traffic converts.
- Key hygiene. Platform API key in a secret store; rotation path tested (
POST /v1/agents/{id}/rotatefor agent keys; re-registering your platform re-issues its key).
What to integrate second #
More actions on the same platform record — each is one more requireKYA({ action }) line and one more scope in the mandate schema: order.place, subscription.create, credits.refill. The chain, caps, revocation, and audit come along for free.