Agent Passport protocol (v2)
This document specifies every byte an independent implementation needs to interoperate with Passport: the canonical JSON form, the signed action request, decision receipts, the audit-chain hash, and the discovery document.
1. Canonical JSON
All signatures and digests are computed over canonical JSON:
- Object keys sorted by UTF-16 code-unit order (JavaScript default sort).
- Members whose value is
undefinedare omitted. - No whitespace.
- Arrays keep their order.
- Numbers must be finite;
NaN/Infinityare errors. Numbers are serialized with JavaScriptJSON.stringifysemantics. - Strings are serialized with
JSON.stringifyescaping. - Encoded as UTF-8.
Reference implementation in the repository: src/lib/protocol/canonical.ts;
the SDK ships an identical copy guarded by a parity test.
canonicalDigest(value) = lowercase hex SHA-256 of the canonical bytes.
2. Signed action request
{
"payload": {
"version": "v2",
"requestId": "8f3d5c0e-0f8e-4a2d-9e7b-1c2d3e4f5a6b",
"agentId": "agt_0123456789abcdefghijkl",
"organizationId": "<uuid>",
"projectId": "<uuid>",
"action": "deploy:production",
"parameters": {
"deploymentId": "dpl_7Kx2QmStagingBuild",
"gitSha": "a1b2…"
},
"source": "github-actions",
"issuedAt": "2026-09-02T12:00:00.000Z",
"expiresAt": "2026-09-02T12:05:00.000Z",
"nonce": "<43 base64url chars = 32 random bytes>",
"justification": "optional free text from the proposer",
"onBehalfOf": "optional principal the agent acts for, e.g. paris@acme.example"
},
"signature": "<86 base64url chars = 64-byte Ed25519 signature>"
}
signature = Ed25519.sign(agentPrivateKey, canonicalBytes(payload)), base64url without padding.agentIdmatches^agt_[A-Za-z0-9_-]{20,}$.actionmatches^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)*$, max 64 chars.source∈github-actions | ci | server | local-dev | mcp.expiresAt − issuedAtmust be in(0, 15 min];issuedAtmay be at most 30 s in the future;expiresAtmust be in the future when received.nonceis unique peragentId(DB unique constraint); reuse →REPLAYED_NONCE(HTTP 409) and anauthorization.replay_rejectedaudit event.onBehalfOf(≤ 200 chars) names the human or system the agent is acting for. It is signed, stored, shown in the dashboard and copied into the receipt, but it is a claim by the agent's runtime, not a verified identity — treat it as attribution, not authentication.requestDigest = canonicalDigest(payload)binds receipts to the exact request.
POST /api/v1/authorization-requests responds:
| HTTP | Meaning | Body |
|---|---|---|
| 201 | recorded as allowed or pending | { id, status, reasonCode, expiresAt, requiredApproval, violations, summary, receipt, authorizationToken, explanation } |
| 403 | recorded as denied | same shape; receipt.claims.decision === "denied" |
| 400/401/403/404/409 | rejected before a record exists | { error, details? } |
authorizationToken is a 32-byte random bearer token shown exactly once. Only
its SHA-256 is stored. It authorizes GET …/:id and POST …/:id/execute.
explanation is the same decision in words, for humans and for the model that
proposed the action:
{
"headline": "Waiting for a human",
"why": "$250.00 is above the $100.00 a human must sign off on",
"next": "Nothing runs until an approver approves it in the dashboard. The request expires at 2026-09-02T13:00:30.000Z.",
"rule": { "policyId": "<uuid>" },
"grant": { "capabilityId": "<uuid>" },
"approval": { "role": "approver", "expiresAt": "2026-09-02T13:00:30.000Z" },
"evidence": {
"reasonCode": "THRESHOLD_APPROVAL_REQUIRED",
"violations": [],
"escalation": "$250.00 is above the $100.00 a human must sign off on",
"receiptKeyId": "sha256:…"
}
}
reasonCode values a recorded decision can carry:
| Code | Status | Meaning |
|---|---|---|
POLICY_ALLOWED | allowed | Grant and rule both say yes. |
THRESHOLD_APPROVAL_REQUIRED | pending | The rule says yes, but the grant's approveAbove / approveTools line was hit. |
APPROVAL_REQUIRED | pending | The rule is require_approval. |
HUMAN_APPROVED | allowed | An approver said yes; approvedBy is set in the receipt. |
HUMAN_DENIED | denied | An approver said no. |
CONSTRAINT_VIOLATED | denied | A grant limit failed; violations[] names the constraint. |
POLICY_DENIED | denied | The rule is deny. |
NO_ACTIVE_POLICY | denied | No rule exists for this project × action. |
SOURCE_DENIED | denied | The request came from a source the rule does not accept. |
RATE_ANOMALY | pending | Allowed on the merits, but the agent is far busier than its own history. A human confirms. |
RATE_ANOMALY is the behavioural check: with at least a week of history, an
agent that has made more than max(20, 3 × its usual daily count) requests in
the last 24 hours is held for approval; with no history the line is 60 a day.
The explanation says both numbers. It never denies and never fires below 20.
Escalation never denies. It only turns an allow into pending, using the
rule's approvalWindowSeconds and requiredApproverRole (default approver).
Decision pipeline (normative order)
parse → time window → agent & project lookup → organization match → signature
→ agent active → action known → parameters valid → capability grant active &
unexpired → constraints → policy (source, decision) → persist (nonce unique)
→ receipt
Every step fails closed. Recorded policy and capability decisions enter the audit log. Rejected input, invalid signatures, and replay failures are errors, not a promise of a persisted request or signed receipt.
3. Actions
| Action | Parameters | Constraints | Executor |
|---|---|---|---|
deploy:staging | { deploymentId: "dpl_…", gitSha?, gitRef? } | { allowedGitRefs?: string[] } (glob *) | Vercel alias to project staging domain |
deploy:production | same | same | Vercel promote |
purchase | { amount (minor units, int), currency (ISO 4217), vendor, description } | { maxAmount, currency, allowedVendors?, dailyLimit?, approveAbove? } | none — authorize-only |
tool | { server, tool, arguments?, amount? } | { allowedServers?, allowedTools?, deniedTools?, dailyCalls?, approveTools?, argumentRules? } | passport-mcp proxy forwards the call |
http | { upstream, method (uppercase), path, query?, body?, amount? } | { allowedUpstreams?, allowedMethods?, allowedPaths?, deniedPaths?, dailyCalls?, approveMethods?, approvePaths? } | Passport proxies the call to a registered upstream |
dailyLimit is a rolling 24-hour budget: the request is denied
(CONSTRAINT_VIOLATED / dailyLimit) when its amount plus the amounts of this
grant's committed authorizations in the last 24 hours — pending inside their
window, allowed, executing or executed — would exceed it. Denied and expired
requests never consume budget; counting pending ones means parallel requests
cannot overshoot before any executes.
argumentRules (on tool) constrains individual tool arguments so a grant can
say "the send_email tool, but only to *@acme.example, never with an
attachment". Each rule is { tool, argument, oneOf? | pattern? | max? | min? | forbidden? }; a failing rule denies with CONSTRAINT_VIOLATED /
argumentRules and names the argument in violations[].
http lets Passport sit in front of any REST API without an SDK on the
receiving side: an admin registers an upstream (base URL, allowed methods,
headers to forward) on a project; the agent signs
GET /v1/invoices on billing; if allowed, POST …/:id/execute performs the
request server-side and returns status, headers and a bounded body. Paths are
resolved against the upstream base URL and may not escape it
(PATH_ESCAPES_UPSTREAM).
New actions register a parameters schema, constraints schema, constraint
evaluator, optional stateful checkUsage, summary and optional executor in
src/lib/actions.
4. Decision receipt
Issued for recorded final allowed and denied decisions, including human
approval / denial. Pending requests and rejected inputs may have no receipt.
Returned inline and from GET /api/v1/authorization-requests/:id. A signature
proves integrity, not current permission to execute.
{
"claims": {
"version": "v2",
"type": "passport.decision",
"keyId": "sha256:61fxADxd_rgY0asjwuIQgHwQhcWz9FfI",
"authorizationId": "<uuid>",
"requestId": "<payload.requestId>",
"requestDigest": "<hex sha256 of canonical payload>",
"organizationId": "<uuid>",
"projectId": "<uuid>",
"agentId": "agt_…",
"action": "deploy:production",
"parameters": { "deploymentId": "dpl_…" },
"decision": "allowed",
"reasonCode": "HUMAN_APPROVED",
"capabilityId": "<uuid>|null",
"approvedBy": "<user uuid>|null",
"onBehalfOf": "paris@acme.example|null",
"issuedAt": "2026-09-02T12:03:10.000Z",
"expiresAt": "2026-09-02T12:08:10.000Z"
},
"signature": "<86 base64url chars>"
}
signature = Ed25519.sign(passportKey, canonicalBytes(claims)).
keyId = "sha256:" + base64url(sha256(SPKI DER of public key))[0:32].
A relying party (e.g. a payment system honouring purchase) MUST check: the
signature against the key named by keyId from the discovery document,
decision === "allowed", expiresAt in the future, and that action,
parameters, agentId, organizationId, projectId equal what it is about to
do. The SDK's verifyReceipt(receipt, publicKeyPem, expectations) does this.
Receipt status
A receipt verifies offline forever; whether Passport still stands behind it is
a separate, live question. GET /api/v1/receipts/:authorizationId/status
(public, rate-limited, no auth — the id is the secret) answers:
{
"status": "consumed",
"valid": false,
"reason": "This authorization has already been used to run the action.",
"checkedAt": "…",
"authorizationId": "<uuid>",
"decision": "allowed",
"requestStatus": "executed",
"expiresAt": "…",
"executedAt": "…",
"agentStatus": "active",
"capabilityStatus": "active"
}
status ∈ valid (allowed, unused, inside its window — or a denial/pending
receipt that faithfully records the current state), expired, consumed
(already executed), revoked (agent revoked since), superseded (the grant
behind it is no longer active), not_found (HTTP 404). The SDK exposes it as
client.checkReceiptStatus(receipt). Relying parties that execute for
themselves SHOULD check this immediately before anything irreversible.
5. Audit chain
One chain per organization. Rows are appended only via the Postgres RPC
append_audit_event, which takes pg_advisory_xact_lock(hashtext('passport.audit.' || org)),
assigns sequence = previous + 1, and computes:
payload_digest = hex sha256(canonical JSON of payload)
event_hash = hex sha256( join("\n", [
"passport-audit-v2", id, organization_id, sequence,
actor_type, actor_id, event_type, entity_type, entity_id,
request_id ?? "", payload_digest, previous_hash ?? "",
created_at as ISO-8601 UTC with milliseconds ]) )
previous_hash = event_hash of sequence − 1 (null for sequence 1)
Export (GET /api/v1/organizations/:id/audit):
{
"format": "passport-audit-export/v2",
"organizationId": "<uuid>",
"exportedAt": "…",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----…",
"head": {
"claims": {
"type": "passport.audit-head",
"keyId": "sha256:…",
"organizationId": "<uuid>",
"sequence": 812,
"eventHash": "<hex>",
"signedAt": "…"
},
"signature": "<86 base64url chars>"
},
"verification": {
"ok": true,
"length": 812,
"headHash": "…",
"headSequence": 812,
"problems": []
},
"events": [{ "id": "…", "sequence": 1, "…": "…", "hash_version": 2 }]
}
A verifier recomputes every payload_digest and event_hash, checks
contiguous sequences and previous_hash links, and verifies head.signature
over canonicalBytes(head.claims); head.claims.sequence/eventHash must equal
the last event. The signed head makes silent truncation detectable.
scripts/verify-audit.mjs is the reference verifier.
6. Discovery
GET /.well-known/passport.json
{
"issuer": "agent-passport",
"protocolVersion": "v2",
"keys": [
{ "keyId": "sha256:…", "algorithm": "Ed25519", "status": "current", "use": ["passport.agent", "passport.decision", "passport.capability", "passport.audit-head"], "publicKeyPem": "…" },
{ "keyId": "sha256:…", "algorithm": "Ed25519", "status": "previous", "use": […], "publicKeyPem": "…" }
],
"endpoints": {
"authorizationRequests": "/api/v1/authorization-requests",
"credentialVerify": "/api/v1/credentials/verify",
"passport": "/api/v1/passports/{agentId}",
"passportVerify": "/api/v1/passports/verify",
"verify": "/api/v1/verify",
"receiptStatus": "/api/v1/receipts/{authorizationId}/status"
},
"actions": [{ "name": "deploy:staging", "title": "…", "risk": "medium", "executable": true }, …]
}
Pin the keys you fetch here; do not trust the publicKeyPem embedded in an
export you cannot otherwise authenticate. keys[0] is always the key signing
today; entries with status: "previous" are retired keys kept so artifacts
signed before a rotation still verify. Always select the key by the keyId the
artifact names (findKey(discovery, keyId) in the SDK) rather than assuming
the first.
7. Portable capability credential
A credential is Passport's signed statement that an agent key holds a grant.
Unlike a decision receipt it is not tied to one request, so a relying party that
Passport does not execute for (a payment API, an internal admin tool, another
agent platform) can honour the grant on its own: verify the credential offline,
verify the agent's signature over the concrete request with the key embedded in
the credential, evaluate constraints itself, then — for anything irreversible
— ask Passport whether the grant still stands.
Issued by POST /api/v1/capabilities/:id/credential (session auth, developer role
or above, audit event capability.credential_issued) or from the dashboard's
Export credential button.
{
"claims": {
"version": "v2",
"type": "passport.capability",
"keyId": "sha256:61fxADxd_rgY0asjwuIQgHwQhcWz9FfI",
"credentialId": "<uuid, unique per export>",
"capabilityId": "<uuid of the grant>",
"organizationId": "<uuid>",
"projectId": "<uuid>",
"agentId": "agt_…",
"agentKeyFingerprint": "sha256:<32 base64url chars of the agent's SPKI digest>",
"agentPublicKeyPem": "-----BEGIN PUBLIC KEY-----…",
"action": "purchase",
"constraints": {
"maxAmount": 50000,
"currency": "USD",
"allowedVendors": ["acme"]
},
"issuer": "https://passport.example",
"issuedAt": "2026-09-03T09:00:00.000Z",
"expiresAt": "2026-12-31T00:00:00.000Z"
},
"signature": "<86 base64url chars>"
}
signature = Ed25519.sign(passportKey, canonicalBytes(claims)), same key and
keyId derivation as receipts. expiresAt mirrors the grant's own expiry and
is null for grants that live until revoked.
Offline verification (verifyCapabilityCredential in the SDK): parse,
verify the signature against the pinned key named by keyId, check
expiresAt, and compare any pinned expectations (action, agentId,
organizationId, projectId, agentKeyFingerprint). Then verify the agent's
signature over the request with agentPublicKeyPem (verifyAgentSignature).
The credential binds to a fingerprint, not a name: rotating the agent's key
invalidates every credential issued for the old key.
Online status — POST /api/v1/credentials/verify (public, rate-limited):
{ "credential": { "claims": {…}, "signature": "…" }, "expect": { "action": "purchase" } }
{
"valid": false,
"status": "revoked",
"reason": "CAPABILITY_REVOKED",
"credentialId": "…",
"capabilityId": "…",
"revokedAt": "…",
"checkedAt": "…"
}
status is one of active, revoked, expired, agent_revoked,
agent_key_rotated, unknown (grant not found under this organization) or
invalid (signature/shape/expectation failure; reason says which). The
endpoint discloses nothing that the credential does not already state. Relying
parties SHOULD cache active answers for at most the grant's remaining
lifetime and no longer than a few minutes.
A credential is not an authorization: it says what the agent may do, not that a specific request was allowed. Relying parties that want Passport's policy engine, approval flow and audit chain in the loop should submit a signed action request (§2) and honour the decision receipt (§4) instead.
8. Agent passport document
The passport is the document an agent carries and a relying party reads: who the agent is (its key), who stands behind it (the organization), what it may do right now (every active grant and how the project's rules gate it) and how much damage it could do unattended (blast radius). Where a credential (§7) speaks for one grant, a passport speaks for the whole agent. It carries no private material and no request history.
Issued by POST /api/v1/agents/:id/passport (session auth, developer role or
above) or from the dashboard's Passport button. When the owner publishes it
(PATCH /api/v1/agents/:id with { "action": "set_passport_visibility", "public": true }) anyone may read it at GET /api/v1/passports/:agentId — a
freshly signed snapshot on every read — and a person can read it at
/p/:agentId. Hidden, revoked and unknown agents answer 404 identically.
{
"claims": {
"version": "v2",
"type": "passport.agent",
"keyId": "sha256:61fxADxd_rgY0asjwuIQgHwQhcWz9FfI",
"passportId": "<uuid, unique per issue>",
"agentId": "agt_…",
"agentName": "Ops bot",
"agentDescription": "Ships GitHub Actions",
"agentKeyFingerprint": "sha256:<32 base64url chars of the agent's SPKI digest>",
"agentPublicKeyPem": "-----BEGIN PUBLIC KEY-----…",
"agentCreatedAt": "2026-09-03T17:47:25.354Z",
"organizationId": "<uuid>",
"organizationName": "Acme",
"organizationSlug": "acme",
"grants": [
{
"capabilityId": "<uuid>",
"projectId": "<uuid>",
"projectName": "storefront",
"action": "deploy:production",
"constraints": {
"allowedBranches": ["main"],
"requireGreenChecks": true
},
"gate": "require_approval",
"expiresAt": "2026-12-31T00:00:00.000Z"
}
],
"blastRadius": { "level": "high", "score": 7 },
"issuer": "https://passport.example",
"profileUrl": "https://passport.example/p/agt_…",
"issuedAt": "2026-09-04T09:00:00.000Z",
"expiresAt": "2026-10-04T09:00:00.000Z"
},
"signature": "<86 base64url chars>"
}
signature = Ed25519.sign(passportKey, canonicalBytes(claims)). gate is how
the project's rule treated the action at issuance: allow, require_approval,
deny, or none when no rule matched. Passports are snapshots valid for 30
days; a relying party that wants today's grants fetches a fresh one from the
issuer rather than trusting an old copy.
Offline verification (verifyAgentPassport in the SDK): parse, verify the
signature against the pinned key named by keyId, check expiresAt, check that
agentPublicKeyPem really hashes to agentKeyFingerprint, and compare any
pinned expectations (agentId, organizationId, agentKeyFingerprint). Then,
as with credentials, verify the agent's own signature over whatever it is asking
you to do with agentPublicKeyPem.
Online status — POST /api/v1/passports/verify (public, rate-limited),
body { "passport": {…}, "expect": { "agentId": "agt_…" } }. The answer
carries valid, status (VALID, GRANTS_CHANGED, AGENT_REVOKED,
AGENT_KEY_ROTATED, AGENT_UNKNOWN, PASSPORT_EXPIRED, BAD_SIGNATURE, …),
outcome (valid, stale, invalid), a reason sentence and the individual
checks. GRANTS_CHANGED means the signature is good but the agent's grants
differ from the ones in the document — fetch a fresh passport.
Any artifact — POST /api/v1/verify with { "document": {…} } accepts a
passport, a capability credential or a decision receipt, works out which it is
from claims.type, and returns the same shape with a kind field plus
human-readable headline/summary. This is what the verifier at /verify
calls; use it when you do not know in advance what you were handed.
9. Approval notifications
When a request lands as pending, and again when a human decides it, Passport
notifies the organization's configured channels (dashboard → Settings). Slack
channels receive a Block Kit message. Webhook channels receive:
POST <your https url>
content-type: application/json
x-passport-event: request.pending | request.decided
x-passport-key-id: sha256:…
x-passport-signature: <base64url Ed25519 over canonicalBytes(body)>
{
"version": "v1",
"type": "request.pending",
"sentAt": "…",
"organizationId": "<uuid>",
"authorizationId": "<uuid>",
"agentId": "agt_…",
"agentName": "Procurement bot",
"projectId": "<uuid>",
"projectName": "Finance",
"action": "purchase",
"summary": "Buy 10 × NVIDIA H100 for $18,420",
"status": "pending",
"reasonCode": "THRESHOLD_APPROVAL_REQUIRED",
"requiredApproval": "approver",
"onBehalfOf": "paris@acme.example",
"expiresAt": "…",
"dashboardUrl": "https://…/dashboard?tab=requests&request=<uuid>"
}
Verify the signature with the discovery key named by x-passport-key-id and
reject anything that does not verify; the body is exactly what was signed, so
verify the raw bytes re-canonicalised (§1), not a re-serialised object.
Deliveries time out after 8 s, do not follow redirects, and are never retried —
the dashboard is the source of truth, notifications are the nudge. Webhook URLs
must be public https (no private ranges, no credentials) and are stored
encrypted.