Agent identity & auth
Agent identity & auth
Engine v0.5.6+.
Login & sessions is about a human with a browser: a password, a cookie, a 2FA code. This page is about the other subject your server talks to — an agent. It doesn't have a browser, it shouldn't hold a long-lived secret, and it often needs to hand a weaker slice of its own authority to a sub-agent it spawned five seconds ago.
Synsema treats that as a first-class case: the runtime knows who is calling, and meters spend and rate limits per identity rather than per IP or per process.
-- Doc example: agent identity — capability tokens that can only narrow, signed
-- requests (proof-of-possession) and per-identity metering.
-- (The server side rides on `serve`, which doesn't terminate — the page shows the
-- full flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: agent identity"
require random
-- Signing a request goes through the same deny-by-default gate as signing a
-- blockchain transaction, scoped to the key's name (here the label given to
-- as_secret) and audited. Without this line http_sign is refused.
require sign("SIG")
require sign("DOC")
require sandbox_run -- only to produce an input for the lineage test below
let root be "root-key-for-the-doctest"
let full be captoken_mint({"net": "*.example.com", "spend": "ETH"}, root,
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
let sub be captoken_attenuate(full, {"net": "api.example.com"},
{"ttl": 60, "spend": {"ETH": 0.01}})
print("delegated token → " + slice(sub, 0, 12) + "…")
test "a capability token verifies and reports what it grants"
let v be captoken_verify(full, root)
assert_ne(v, nothing)
assert_eq(v.id, "orchestrator")
assert_eq(v.depth, 1)
assert(captoken_allows(v, "net", "api.example.com"))
test "attenuating needs no key, and the result is strictly weaker"
let v be captoken_verify(sub, root)
assert_eq(v.depth, 2)
-- the delegated host still works…
assert(captoken_allows(v, "net", "api.example.com"))
-- …but the rest of the parent's glob does not, and `spend` was not delegated
assert_eq(captoken_allows(v, "net", "other.example.com"), false)
assert_eq(captoken_allows(v, "spend", "ETH"), false)
-- the spend caveat came down from 0.5 to 0.01 (any unit: fiat, crypto, commodities)
assert_eq(v.caveats.spend.ETH, "0.01")
test "attenuation can never widen — the error names the problem"
assert_error(() => captoken_attenuate(sub, {"net": "*.example.com"}, nothing))
assert_error(() => captoken_attenuate(sub, {"exec": nothing}, nothing))
assert_error(() => captoken_attenuate(sub, {"net": "api.example.com"}, {"ttl": 99999}))
test "a forged or tampered token is nothing, never a partial success"
assert_eq(captoken_verify(full, "another-root"), nothing)
assert_eq(captoken_verify("not-a-token", root), nothing)
assert_eq(captoken_verify(slice(full, 0, 20), root), nothing)
test "expiry and revocation"
let short be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "id": "temp-1"})
assert_ne(captoken_verify(short, root), nothing)
-- `at` moves the clock forward for a deterministic test
assert_eq(captoken_verify(short, root, {"at": 4102444800}), nothing)
-- revocation is a denylist of ids (typically read from redis)
assert_eq(captoken_verify(short, root, {"revoked": ["temp-1"]}), nothing)
test "contextual caveats are fail-closed"
let scoped be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "aud": "orders-api"})
assert_ne(captoken_verify(scoped, root, {"aud": "orders-api"}), nothing)
assert_eq(captoken_verify(scoped, root, {"aud": "other-api"}), nothing)
-- not supplying the context at all is a rejection: you cannot claim a
-- condition holds if you never checked it
assert_eq(captoken_verify(scoped, root), nothing)
test "signed requests: the signature covers method, URL and body"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "POST", "url": "https://api.example.com/orders", "body": "{\"n\":1}"}
let headers be http_sign(req, key, {"alg": "hmac-sha256", "keyid": "agent-7"})
let incoming be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":1}"}
let v be http_signature_verify(incoming, key, {"alg": "hmac-sha256"})
assert_eq(v.keyid, "agent-7")
-- same signature, different body → the content-digest no longer matches
let tampered be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":999}"}
assert_eq(http_signature_verify(tampered, key, {"alg": "hmac-sha256"}), nothing)
test "the verifier pins the algorithm — never reads it from the message"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "GET", "url": "https://api.example.com/health"}
let headers be http_sign(req, key, {"alg": "hmac-sha256"})
let incoming be {"method": "GET", "url": "https://api.example.com/health", "headers": headers}
-- a verifier expecting ed25519 rejects an hmac message outright
assert_eq(http_signature_verify(incoming, key, {"alg": "ed25519"}), nothing)
-- and omitting alg is a programmer error, not a permissive default
assert_error(() => http_signature_verify(incoming, key))
test "oidc_verify demands iss and aud"
assert_error(() => oidc_verify("a.b.c", {"aud": "my-client", "jwks": "{}"}))
assert_error(() => oidc_verify("a.b.c", {"iss": "https://idp", "jwks": "{}"}))
-- a bad token with complete options is nothing, not an error
assert_eq(oidc_verify("not-a-jwt", {"iss": "https://idp", "aud": "my-client", "jwks": "{}"}), nothing)
test "spend_total reads per-identity totals"
assert_eq(spend_total("ETH"), 0)
assert_eq(spend_total("ETH", "orchestrator"), 0)
test "the token is the ceiling: caveats that shape execution, and what a token can never carry"
-- `deterministic` = the holder runs without clock or entropy; `llm_tokens` = a delegated
-- LLM budget. Both travel in the token and only ever narrow.
let frozen be captoken_mint({"net": "x.com"}, root,
{"ttl": 60, "deterministic": true, "llm_tokens": 1000})
let v be captoken_verify(frozen, root)
assert_eq(v.caveats.deterministic, true)
assert_eq(v.caveats.llm_tokens, 1000)
-- a sub-agent can get less budget, never more — and cannot turn the clock back on
assert_ne(captoken_attenuate(frozen, {"net": "x.com"}, {"llm_tokens": 500}), nothing)
assert_error(() => captoken_attenuate(frozen, {"net": "x.com"}, {"llm_tokens": 5000}))
-- process-local capabilities are not transferable authority: nobody delegates
-- another process's stdout or clock, so a token cannot even be minted with them
assert_error(() => captoken_mint({"stdout": nothing}, root, {"ttl": 60}))
assert_error(() => captoken_allows(v, "time"))
-- ---- A portable identity (did:key), signed documents, receipts and passkeys ----
-- `require sign("DOC")` (preamble) gates signing with the seed sealed under that label.
let DOC_SEED be bytes("0303030303030303030303030303030303030303030303030303030303030303", "hex")
let OTHER_DID be did_key_encode(bytes("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "hex"))
test "canonical JSON is deterministic bytes, and refuses what it cannot carry exactly"
assert_eq(canonical_json({"b": 1, "a": [true, nothing, "é"]}), "{\"a\":[true,null,\"é\"],\"b\":1}")
-- an integer beyond 2^53 would be rounded by any JCS consumer: put it in the document as text
assert_error(() => canonical_json({"big": 9007199254740993}))
-- bytes and secrets have no JSON form: encode the bytes first; a secret is never serialized
assert_error(() => canonical_json({"raw": DOC_SEED}))
assert_error(() => canonical_json({"k": as_secret("x", "DOC")}))
test "a key is an identifier: did:key round-trips and resolves to a DID Document offline"
let k be as_secret(DOC_SEED, "DOC")
let did be did_key_encode(ed25519_pubkey(k))
assert(starts_with(did, "did:key:z6Mk"))
let parts be did_key_decode(did)
assert_eq(parts.alg, "ed25519")
assert_eq(parts.public_key, ed25519_pubkey(k))
assert_eq(parts.did, did)
let doc be did_key_document(did)
assert_eq(doc.id, did)
assert_eq(doc.verificationMethod[0]["type"], "Multikey")
assert_eq(doc.assertionMethod[0], did + "#" + parts.multibase)
assert_error(() => did_key_decode("did:key:zNotAKey"))
test "a signed document verifies for anyone with the did — and nothing else does"
let k be as_secret(DOC_SEED, "DOC")
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let signed be document_sign({"offer": 12, "unit": "credits"}, k, {"verification_method": did + "#" + parts.multibase})
assert_eq(signed.proof["type"], "DataIntegrityProof")
assert_eq(signed.proof.cryptosuite, "eddsa-jcs-2022")
assert_eq(signed.proof.proofPurpose, "assertionMethod")
let v be document_verify(signed, did)
assert_eq(v.verified, true)
assert_eq(v.verification_method, did + "#" + parts.multibase)
-- one field changed → nothing; another key's did → nothing
set signed["offer"] to 13
assert_eq(document_verify(signed, did), nothing)
assert_eq(document_verify(document_sign({"offer": 12}, k, nothing), OTHER_DID), nothing)
-- the public key is never a secret: handing the signing key as the verifier is an error
assert_error(() => document_verify(signed, k))
test "the same key signs JWTs, verified by did — no JWKS, no network"
let k be as_secret(DOC_SEED, "DOC")
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let tok be jwt_sign({"sub": "agent-7", "iat": 1750000000, "exp": 1750003600}, k, {"alg": "EdDSA", "kid": did + "#" + parts.multibase})
let claims be jwt_verify(tok, {"did": did}, {"now": 1750000001})
assert_eq(claims.sub, "agent-7")
assert_eq(jwt_verify(tok, {"did": OTHER_DID}, {"now": 1750000001}), nothing)
assert_eq(jwt_verify(tok, {"did": did}, {"now": 1750009999}), nothing)
test "a receipt is derived from the audit and verifies as a credential"
let k be as_secret(DOC_SEED, "DOC")
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let r be receipt({"sign": k, "verification_method": did + "#" + parts.multibase, "result": {"answer": 42}})
assert_eq(r["type"][1], "SynsemaReceipt")
assert_eq(r.issuer, did)
assert_ne(r.credentialSubject.program_sha, nothing)
-- the result is the one thing the program declares, and the field says so
assert_ne(r.credentialSubject.declared_result_sha256, nothing)
-- the issuer is derived from the signing key: not an option
assert_error(() => receipt({"sign": k, "issuer": "did:key:zOther"}))
-- the audit inside is the engine's: the `sign` grants used EARLIER in this program are listed
-- (the receipt's own signature comes after the snapshot; nothing can cover itself)
let used be 0
each e in r.credentialSubject.capabilities
when contains(e.capability, "sign") and e.granted
set used to used + 1
assert(used >= 1)
assert_eq(receipt_verify(r, did).verified, true)
set r["issuer"] to "did:key:zImpostor"
assert_eq(receipt_verify(r, did), nothing)
-- unsigned: the same document, no proof
assert_eq(contains(receipt(), "proof"), false)
test "lineage: every input the unit read is recorded, and the receipt publishes it without the data"
-- v0.6.29+. A child program's result is an input (so is a file, an HTTP body, a SQL answer…)
let opts be {"ceiling": "stdout", "timeout": 20}
let child be run_program("print(6 * 7)", opts)
let e be where(lineage(), (x) => x.source == "run_program")[0]
-- `sha256` (hex, no 0x) covers the bytes the program received; `encoding` says which bytes
assert_eq(e.encoding, "jcs")
assert_eq("0x" + e.sha256, hex(sha256(canonical_json(child))))
assert_eq(e.bytes, length(canonical_json(child)))
-- the arguments are a SALTED commitment: nobody recovers them by hashing guesses…
assert(starts_with(e.what, "query sha256-salted:"))
-- …but the owner, who keeps the salt locally, can reveal them later and anyone can check
assert_eq(e.committed_encoding, "jcs")
let args be ["print(6 * 7)", opts]
assert_eq("query sha256-salted:" + slice(hex(sha256(bytes(e.salt, "hex") + bytes(canonical_json(args), "utf8"))), 2), e.what)
-- the receipt carries the same entry as `inputs` — without the salt
let published be where(receipt().credentialSubject.inputs, (x) => x.source == "run_program")[0]
assert_eq(published.what, e.what)
assert_eq(published.sha256, e.sha256)
assert_eq(contains(published, "salt"), false)
test "passkeys: a malformed request is an error with the fix, never a silent nothing"
let opts be {"rp_id": "app.example", "origin": "https://app.example", "challenge": "AAAA"}
-- no `response` at all: that is a bug in the caller, not a failed login
assert_error(() => webauthn_register({"id": "x"}, opts))
-- the key alone is not a credential: the id must be bound to it (identity is the STORED one)
assert_error(() => webauthn_verify({"id": "x", "response": {}}, {"kty": "EC"}, opts))
-- a missing rp_id is a programmer error (the verifier cannot guess what to pin)
assert_error(() => webauthn_verify({"id": "x", "response": {}}, {"id": "x", "public_key": {"kty": "EC"}}, {"origin": "https://app.example"}))Why not just give the agent an API key§
An API key in an agent's context is a bearer credential inside the most leak-prone place in the system — a prompt. Three properties change that:
- It shouldn't be long-lived. Cloud workloads already have an identity
(oidc_verify below); terminals can use a device flow. Nothing to steal.
- It shouldn't be usable if copied. A signed request (
http_sign) proves
possession of a key that never leaves the sealed secret.
- It shouldn't be delegated whole. When an orchestrator spawns a sub-agent, the
universal practice today is passing along the same key — total impersonation. Capability tokens let you hand over strictly less.
Capability tokens — delegation that can only narrow§
A captoken says what the holder may do, and the holder can weaken it offline, without the root key:
-- the spend unit is whatever the host works in: fiat, crypto, commodities, credits
let full be captoken_mint({"net": "*.example.com", "db": "postgres://localhost/app", "spend": "ETH"},
secret("ROOT_KEY"),
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
-- handing work to a sub-agent: one host, no database, a fiftieth of the budget
let sub be captoken_attenuate(full, {"net": "api.example.com", "spend": "ETH"},
{"ttl": 60, "spend": {"ETH": 0.01}})
captoken_attenuate takes no key — that's the whole point, and why delegation doesn't need a round trip to whoever minted the token. It works because each block of the chain is signed with the previous signature as its key (the macaroon construction): the holder can append a block, never remove or edit one.
The permissions are the same shapes as require — net("api.example.com"), db("postgres://host/db"), file.read("./data/*") — and narrowing is checked with the same covers() relation the capability system uses, so a scope in a token means exactly what it means in a require. Widening is refused twice: when you attenuate (with an error that says what didn't fit) and again when the token is verified, so a hand-forged block can't widen either.
Verifying, and what "fail-closed" means here§
let caps be captoken_verify(token, secret("ROOT_KEY"),
{"aud": "orders-api", "ip": request.ip, "method": request.method})
when caps == nothing
give unauthorized("bad token")
when not captoken_allows(caps, "net", target_host)
give fail(403, "that host is not in your token")
Every failure — bad signature, expired, forged, revoked, wrong audience — is nothing, with no hint about which one: an endpoint must not be distinguishable by why it rejected you. And a caveat you don't supply context for is a rejection, not a pass: if the token says aud: "orders-api" and you never pass aud, you cannot claim the condition holds.
Revocation, said out loud§
Attenuation is offline, so there is no central check to hang revocation on. The design answer is two things, and you should decide both before an incident, not during one:
- Short TTLs. The default is 15 minutes on purpose.
- A denylist of ids.
captoken_verify(t, k, {"revoked": [...]}), with the list
typically read from redis. id is what you revoke; set it explicitly for tokens you'll want to name later.
The token is the ceiling§
A verified token is not advisory. Its caps become a delegated ceiling — a second ceiling under the host's — for the unit of work that runs under it, and there are three ways to run under it:
- A
serverequest. Return the map ofcaptoken_verifyfromauth withand the
handler runs with caps_effective ⊆ require ∩ host ceiling ∩ token. A capability the program declares but the token does not carry is denied at use — db in a handler whose caller only holds {"net": …} fails, llm and judge included. The client sees 403 {"error": "insufficient permissions", "status": 403}, a fixed body that never names the capability (a login says "credentials don't match", never "the password is wrong"); the detail goes to the server log and to the audit (above delegated ceiling (token <id>)). A denial the program caused itself — no require — stays a 500: that one is the server's fault.
- A child program.
run_program(src, {"ceiling": verified})runs the child under the
token's authority (Sandbox).
- A block.
sandbox under verifiedruns a block under it, in-process, withrequire
inside still a no-op (Capabilities).
require net("api.example.com")
require db("orders")
-- the caller's token decides what THIS step may touch
let agent be captoken_verify(token, secret("ROOT_KEY"), {"aud": "orders-api"})
sandbox under agent
let rows be sql(db, "select id from orders where owner = ?", [agent.id])
What a token carries is transferable authority: net, file, exec, env, db, llm, judge, serve, secret, reveal, sign, wallet, spend, memory, sandbox_run, attest. The process-local capabilities — stdout, stdin, time, random — are never in a token, because nobody can delegate another process's clock: captoken_mint({"time": nothing}, …) is an error, and a token that says nothing about time leaves now() working. Two caveats constrain execution instead of granting authority: deterministic: true runs the holder without clock or entropy (the same thing --deterministic does, requested by the issuer; once set, no attenuation turns it off), and llm_tokens: N is a delegated LLM budget, metered on the token's id beside SYNSEMA_LLM_BUDGET_PER_IDENTITY and only ever narrowed.
And the subject travels with the ceiling: an agent spawned from the handler, a parallel_map worker and a run_program child run on behalf of the same identity, under the same delegated ceiling, spend limits and LLM budget — spend inside a spawned agent is booked to who asked, not to the agent's name. A cron tick runs as cron:<job>; synsema run runs as the operator (SYNSEMA_IDENTITY, optional).
Signed requests (proof-of-possession)§
Instead of sending a token that anyone who copies it can replay, sign the request:
let key be secret("AGENT_KEY") -- inside the handler, see the pitfall below
let req be {"method": "POST", "url": "https://api.partner.com/orders", "body": payload}
let headers be http_sign(req, key, {"alg": "ed25519", "keyid": "agent-7"})
let res be http_post("https://api.partner.com/orders", payload, headers)
Synsema implements a pinned profile of RFC 9421: the signature always covers @method, @target-uri and content-digest (the digest goes even for an empty body — if it were optional, someone could attach a body to a signed request without breaking the signature), plus created, keyid and alg. No arbitrary component lists, no general structured-field canonicalisation: that surface is where implementations get quietly wrong.
Signing goes through the same door as signing a blockchain transaction: require sign("AGENT_KEY"), deny-by-default, and every signature lands in the audit log. The key is a sealed secret and never becomes a language value.
On the receiving end, http_signature_verify is the symmetric half — and it requires you to pin the algorithm:
let v be http_signature_verify(incoming, pubkey, {"alg": "ed25519"})
That's not ceremony. If the verifier took alg from the message, an attacker would switch it to hmac-sha256 and sign using your public key as the HMAC secret — the same confusion attack that broke JWT libraries for years. The verifier chooses the algorithm; the message never does. The default anti-replay window is ±300s on created (a future timestamp is rejected too — otherwise it would be a deferred replay), and if the client sent a nonce, verification hands it back so you can check it against your own replay store on mutating routes.
Third-party identity: OIDC and cloud workloads§
jwt_verify is for tokens you signed with your secret. For a token from Google, GitHub, Auth0 — or from the cloud your agent is running in — use oidc_verify:
require net("www.googleapis.com")
let claims be oidc_verify(id_token, {
"iss": "https://accounts.google.com",
"aud": "your-client-id.apps.googleusercontent.com",
"jwks_url": "https://www.googleapis.com/oauth2/v3/certs"
})
iss and aud are mandatory, and that is a deliberate design decision rather than an oversight to fix later: a token minted by the same provider for a different application has a perfectly valid signature. Checking the signature without the audience is the classic confused-deputy hole. RS256 and ES256 are supported (RSA below 2048 bits is refused); the JWKS is fetched over net, cached for 10 minutes, and re-fetched when a kid shows up that isn't in the cached set — which is exactly what key rotation looks like.
Note which failures are nothing and which are errors: a bad token is nothing; a JWKS that can't be fetched is an error. "I couldn't verify it" must never be mistaken for "it isn't valid".
This is what makes workload identity work: an agent on AWS, GCP, Azure or GitHub Actions already has an identity issued by the platform. Read it from the metadata service, exchange it for short-lived credentials, and there is no long-lived secret in your .env at all. (On AWS always use IMDSv2 — the token-first flow; v1 is the classic SSRF vector.)
mTLS — identity by certificate§
For service meshes and partners that require client certificates:
require file.read("./certs/*")
mtls_identity("./certs/agent.pem", "./certs/agent.key",
{"hosts": ["*.mesh.internal", "vault.example"]})
-- https:// requests to those hosts present the certificate; the rest don't
It's per process, not per request, because a certificate identifies the workload — the same idea as SPIFFE.
opts.hosts takes a list of hosts (or a single host as text) and follows the same wildcard rule as require net: ".mesh.internal" covers the domain and its subdomains. Omit it and the certificate goes to every host the program can reach — already bounded by require net, so a narrow net scope contains it on its own. Declare hosts when your net scope is broad: presenting a client certificate is saying "I am this agent"*, and a third party that merely asks for one shouldn't be able to harvest your workload identity.
Terminating mTLS on the serve side (verifying incoming client certs) isn't in the engine yet; put a reverse proxy in front for that.
Serving agents: identity, quotas and discovery§
The server side lives in Serve. The short version:
- One
auth withtask handles both kinds of subject — return the verified captoken
for an agent, the session for a human, nothing for neither.
- The runtime picks the identity out of what you returned (
id,sub,keyid,
or a plain text) and uses it to meter: - rate limit per identity in addition to the per-IP shield that runs before auth (that shield is what stops an anonymous flood from burning a worker on every auth attempt); - spend, booked per identity in the ledger and in the audit line — and a captoken's spend caveat becomes a real ceiling the server enforces, which is how an orchestrator caps a sub-agent's budget for real rather than by convention. Three ceilings can apply at once and the strictest wins: per unit (SYNSEMA_SPEND_CEILING="EUR:500,ETH:0.1"), per identity (SYNSEMA_SPEND_CEILING_PER_IDENTITY="agent-1=EUR:50" — its own variable, with = before the unit, so a unit containing : can never collide with an identity key) and the delegated one. The unit is free text and no currency is privileged: fiat, crypto, commodities, credits, kWh — amounts keep up to 28 decimal places, so an 18-decimal crypto unit fits whole; - capabilities — the token's caps are the request's ceiling (see The token is the ceiling above): what the token does not carry is denied, and the client gets a generic 403 insufficient permissions.
/.well-known/synsema-authpublishes, in JSON, which mechanisms the server
understands and which endpoints are protected — the machine-readable companion to /llms.txt, for an agent that never read this page.
/.well-known/agent-card.jsonis the server's Agent Card: who it is (its
did:key), what it offers (one skill per route), how to authenticate, all derived from the route table and signed with SYNSEMA_IDENTITY_KEY — see The Agent Card below.
Passkeys — WebAuthn, the human side of proof-of-possession§
A passkey is a key pair the browser or the device holds. The server never sees a secret, only signatures over a challenge it chose — http_sign for humans. Two builtins verify what navigator.credentials.create() and .get() hand back, in the exact form the browser serialises them (PublicKeyCredential.toJSON(): {id, rawId, response: {…}}, base64url):
require serve(8080)
require random
serve on 8080
-- enrolment: the page posts the credential; the challenge was generated with
-- random_bytes(32) and kept in the session
route "POST /passkeys"
let body be json of request
let reg be webauthn_register(credential of body, {"rp_id": "app.example", "origin": "https://app.example", "challenge": challenge of body})
-- reg = {id, public_key (a JWK), alg, sign_count, fmt, aaguid, user_verified, ...}
give {"id": reg.id, "public_key": reg.public_key, "sign_count": reg.sign_count}
-- login: the same shape, verified against the stored key and counter
route "POST /login"
let body be json of request
let stored be stored_passkey of body
-- `stored` is the map register returned, plus the sign_count and user_handle you keep
let v be webauthn_verify(assertion of body, stored, {"rp_id": "app.example", "origin": "https://app.example", "challenge": challenge of body})
when v == nothing
give fail(401, "sign in failed")
-- v = {id, user_handle, alg, sign_count, user_present, user_verified, backup_eligible, backup_state}
-- id and user_handle are the STORED ones: the assertion cannot declare an identity
give {"user": v.user_handle, "sign_count": v.sign_count}
What the verifier checks, all of it and in order: the ceremony type, the challenge, the origin (one, or a list when you have several front ends), the rp_id hash, the flags (user_verification: "required" demands UV), the signature over authenticatorData ‖ sha256(clientDataJSON), and the counter when you pass the stored one — a counter that does not advance is a cloned key, and is rejected — and that the assertion's credential id is the stored one. That last check is why the second argument is the whole stored credential and not the key alone: rawId and userHandle are not covered by the signature, so the identity that comes back is the one you stored next to the key, never the one the assertion declares. The algorithm is fixed by the registered key (ES256, RS256 or EdDSA), never read from the message. Every verification failure is nothing; only a malformed shape or a missing option is an error, with the fix in the message.
Two decisions, said out loud. Attestation is ignored on purpose: fmt is reported and attStmt is not verified, because trusting the brand of a key means shipping manufacturer trust lists and their revocations — a dependency this engine does not take. The counter is yours to store: the builtins keep no state, so sign_count comes back and is expected back.
Because webauthn_verify returns the credential id as id, an auth with task that returns its map gives the request an identity — quotas, spend and the audit line follow, with no adapter.
A portable identity: did:key, canonical JSON and signed documents§
A registry is a dependency; a key is not. did:key turns a public key into an identifier any verifier resolves offline (did:key:z6Mk… for ed25519, did:key:zDn… for P-256), and W3C Data Integrity lets you sign a JSON document so that a verifier who has never heard of Synsema still checks it. Under both sits canonical JSON (RFC 8785): the same document always yields the same bytes, so hashing and signing are well defined.
require secret("ID_SEED")
require sign("ID_SEED") -- signing with a secret goes through the sign gate, audited
let k be secret("ID_SEED") -- a 32-byte ed25519 seed, hex in .env
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let vm be did + "#" + parts.multibase -- the verificationMethod URL
let offer be {"unit": "credits", "amount": 12, "for": "agent-7"}
let signed be document_sign(offer, k, {"verification_method": vm, "created": "2026-09-22T00:00:00Z"})
-- signed.proof = {type: "DataIntegrityProof", cryptosuite: "eddsa-jcs-2022",
-- verificationMethod, proofPurpose, created, proofValue}
print(text(document_verify(signed, did))) -- {verified: true, cryptosuite: ..., ...} — or nothing
Two cryptosuites: eddsa-jcs-2022 (an ed25519 secret, through require sign, or an Ed25519 PKCS#8 PEM as text) and ecdsa-jcs-2019 (P-256: a scalar secret, or a SEC1/PKCS#8 PEM as text — no gate, the same rule as ecdsa_p256_sign). Omit verification_method and the proof names the signing key's own did:key. document_verify takes the public key as bytes, a did:key, a public-key PEM or a JWK, and returns nothing on any failure: tampered, wrong key, wrong suite, a challenge or domain that does not match.
The same key signs JWTs — jwt_sign(claims, k, {"alg": "EdDSA", "kid": vm}) — and the other side verifies them with jwt_verify(token, {"did": did}): no JWKS, no fetch, the key is in the identifier. did_key_document(did) returns the DID Document a resolver would, computed locally.
canonical_json refuses what JCS cannot carry exactly instead of approximating: integers beyond 2^53 and decimals with more than 15 significant digits (put them in the document as text), bytes (encode them first), secrets (never).
Receipts — what a unit of work did, derived and signed§
An agent that reports on itself is asking to be trusted. A receipt is what the engine saw: the identity the unit ran as, the capability tokens in force, every capability asked for — granted or denied, with reason and source —, the spend booked to that identity, every declassify, the step count, the program's hash, the engine version, the hash of the result and — since engine v0.6.29 — every input the unit read (its lineage, below). receipt() returns it as a Verifiable Credential; receipt({"sign": k, …}) signs it as a Data Integrity proof, so a client, an auditor or an ERC-8004 validator verifies it with receipt_verify — or with any VC library — without knowing what Synsema is. Two fields the program cannot write: issuer is always the did:key of the signing key, and validFrom (with the proof's created) is the engine's clock — omitted when the unit has no time — so a receipt cannot be antedated or issued in someone else's name, and receipt_verify rejects an issuer that is not the verifying key. The one thing the program does declare is the result it hands in, and the field says so: declared_result_sha256.
require serve(8080)
require secret("ID_SEED")
require sign("ID_SEED")
require llm
serve on 8080
route "POST /answer"
let k be secret("ID_SEED")
let did be did_key_encode(ed25519_pubkey(k))
let parts be did_key_decode(did)
let answer be reason(question of (json of request))
-- with `auth with` wired, the receipt's id is the request's identity; its audit is this request's own
give {"answer": answer, "receipt": receipt({"sign": k, "verification_method": did + "#" + parts.multibase, "result": answer})}
What a receipt cannot promise, said out loud: completeness. An agent only shows the receipts it likes, and no cryptography fixes that — ask for one per request and count them yourself. What it does promise is that every receipt is true and verifiable: the fields are derived by the engine, never written by the program.
Lineage — which data went in§
Since engine v0.6.29 the engine records every piece of data the program reads from outside, in order: files (read_file, list_dir, grep, parquet_read…), HTTP answers, SQL/Mongo/Redis reads, chain reads (EVM, Solana, Algorand, Bitcoin), socket and process messages, read_line, model answers (reason/decide/analyze/generate) and what run/run_program returned. The program cannot add, edit or drop an entry. lineage() returns the list so far; receipt() publishes it as credentialSubject.inputs, so a signed receipt proves which inputs, which program and which output.
Each entry is {source, what, sha256, bytes, encoding}:
source— the builtin that read it (llmfor a model answer).what— only a path the program passed as text, a host (neveruser:pass@, the
path or the query; a 4xx/5xx answer adds status N), a size (bytes <n> for a file passed as bytes) or a salted commitment — query sha256-salted:<hex> for a SQL query, a Mongo filter, run arguments, an RPC call or a prompt. The receipt never carries data.
sha256— hex, without0x, of the bytes the program received;bytesis their
length.
encoding— which bytes were hashed, so anyone recomputes it:"text"(UTF-8),
"bytes", "jcs" (canonical_json(x)), "json" (json_encode(x), when the value holds an integer beyond 2^53) or "display" (the printed form, for what neither can carry).
A commitment is sha256(salt ‖ canonical_json([args…])) with a fresh 128-bit salt per entry, like an SD-JWT disclosure: nobody recovers the query by hashing guesses. The salt and its committed_encoding stay in the local lineage() and never reach the receipt, so the owner can reveal one query later and anyone can check it:
require sandbox_run
let opts be {"ceiling": "stdout", "timeout": 20}
let child be run_program("print(6 * 7)", opts)
let e be lineage()[0]
print(e.source, e.encoding, "0x" + e.sha256 == hex(sha256(canonical_json(child)))) -- run_program jcs true
-- revealing the arguments: hand over the salt and the arguments, and anyone recomputes `what`
let args be ["print(6 * 7)", opts]
print("query sha256-salted:" + slice(hex(sha256(bytes(e.salt, "hex") + bytes(canonical_json(args), "utf8"))), 2) == e.what) -- true
print(length(receipt().credentialSubject.inputs)) -- 1
Only reads that arrived count (a request that failed on the network is not an input). Not recorded: what the program writes, env/secret, the blackboard. The list stops at 10,000 entries and the receipt then says inputs_truncated: true. To fix a signed receipt in time, anchor sha256(canonical_json(receipt)) on a chain — see Attestation for the other half: which code read it.
The Agent Card — who your server is, derived and signed§
Every serve publishes /.well-known/agent-card.json (alias /.well-known/agent.json): the A2A Agent Card shape, derived from the route table like /openapi.json — so it is never stale — and signed with the server's key, so nobody in between can swap it.
name,description,version,documentationUrl— fromdescribeand the
base URL. The shape is exactly message AgentCard of A2A 1.0's a2a.proto, in its proto-JSON form: a strict parser accepts it whole.
- one skill per route (
id= the OpenAPI operation id, tagsauth/stream/socket,
input and output modes), securitySchemes (httpAuthSecurityScheme / apiKeySecurityScheme) and securityRequirements when auth with is wired, capabilities.streaming when a route streams.
- an extension inside
capabilities.extensions(https://synsema.org/ext/identity/v1)
with what A2A does not model: the server's did:key, the base URL, /openapi.json, /.well-known/synsema-auth, /.well-known/attestation under --attested, the engine version, and capabilityTokens: true — a captoken's caps are the request's ceiling here.
supportedInterfaces: [], always. A Synsema server does not speak the A2A
transport (message/send), and the card never claims it. What is true is there, and an A2A client reads it.
The signature is A2A's (§8.4): a JWS in JSON serialisation — signatures: [{protected, signature}] — over the canonical JSON of the card without signatures, kid = the did's verification method (did:key:z…#z…). The key is SYNSEMA_IDENTITY_KEY (the ed25519 seed, 64 hex, in .env; EdDSA) or, under serve --attested, the attested P-256 key (ES256) — then the card and the attestation document speak of the same key. Without a key the card is served unsigned and without did: nothing is invented.
Verify another server's card from Synsema, offline:
require net("other.example")
require time
let card be json_decode(fetch("https://other.example/.well-known/agent-card.json"))
let unsigned be remove(card, "signatures") -- a new map; `card` keeps its signatures
let sig be card.signatures[0]
let tok be sig.protected + "." + decode(bytes(canonical_json(unsigned)), "base64url") + "." + sig.signature
let did be card.capabilities.extensions[0].params.did
when jwt_verify(tok, {"did": did}) == nothing
print("the card does not verify")
ERC-8004. The on-chain agent registry points its services at exactly this URL and at the did, and its feedbackHash/requestHash at documents like the receipts above. Synsema keeps the registry a client: examples/erc8004/ in the engine repository builds the registration file and the calldata of the Identity, Reputation and Validation registries with abi_encode — no new primitive, no new dependency.
Choosing between the mechanisms§
| You need | Use |
|---|---|
| A sub-agent to do less than you can | captoken_mint + captoken_attenuate |
| A stolen credential to be worthless | http_sign / http_signature_verify |
| "Log in with Google/GitHub" | oidc_verify |
| No secret in the environment at all (cloud) | workload identity → oidc_verify |
| A service mesh that demands client certs | mtls_identity |
| A human with a browser | Login & sessions |
| A human (or a device) with a passkey | webauthn_register / webauthn_verify |
| An identity anyone verifies, with no registry | did:key + document_sign / jwt_sign EdDSA |
| Proof of what a unit of work did | receipt / receipt_verify |
| Tell other agents — or a registry — who your server is | the Agent Card at /.well-known/agent-card.json |