Sign your own assertion
The gateway wire protocol in full. Everything you need to authenticate an agent from any language that can sign Ed25519.
There is no SDK for your language, or you want to see the protocol with nothing in the way. Both are reasonable, and the gateway cannot tell the difference — which is exactly the property you want from a security boundary.
#The assertion
A compact JWS, EdDSA over Ed25519:
textbase64url(header) . base64url(canonicalJson(payload)) . base64url(signature)
Header
json{ "typ": "veefore-assertion+jwt", "alg": "EdDSA", "kid": "key_8f2c1d4e5a6b7c8d" }
Payload
json{
"sub": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"aud": "https://gateway.veefore.example.com",
"purpose": "agent_authentication",
"nonce": "YmFzZTY0dXJsLXJhbmRvbQ",
"iat": "2026-10-03T12:38:04.000Z",
"exp": "2026-10-03T12:40:04.000Z"
}
substringrequiredThe agent id.
audstringrequiredThe gateway audience, compared as a string. Fetch it; do not assume it.
purposestringrequiredagent_authenticationfor/token,handshakefor/handshake. They are not interchangeable — an assertion minted for one is refused for the other.noncestringrequiredSingle use. 18 random bytes, base64url, is what the SDKs generate. Reuse is
NONCE_REPLAYED.iatISO-8601requiredIssued at, UTC.
expISO-8601requiredExpiry. The SDKs use
iat + 120s. Keep it short; a long-lived assertion is a bearer credential.
iat and exp are ISO-8601 strings, not numeric epoch seconds. This
departs from common JWT practice deliberately — every other timestamp in the
Veefore API is ISO-8601, and a single format across the whole surface is worth
more than conformance to a convention here.
#Canonical JSON
The payload is canonicalised before signing. This must match byte for byte or the signature fails.
Rules, RFC 8785-style:
- Object keys sorted by Unicode code point, ascending
undefinedvalues omitted entirely- No insignificant whitespace
- Non-finite numbers become
null Datevalues become their ISO-8601 string
text{"aud": "https://gateway.veefore.example.com","exp":"2026-10-03T12:40:04.000Z","iat":"2026-10-03T12:38:04.000Z","nonce":"YmFzZTY0dXJsLXJhbmRvbQ","purpose":"agent_authentication","sub":"agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0"}
The header is JSON.stringify'd, not canonicalised — its three keys are
already in a fixed order. Only the payload is canonicalised.
Ed25519 signs the message, not a digest
Ed25519 is a one-shot scheme. Sign the raw UTF-8 bytes of
base64url(header) + "." + base64url(canonicalPayload). Do not pre-hash with
SHA-256 and sign the digest — that produces a valid-looking signature over the
wrong message, and it is the most common mistake in a from-scratch
implementation.
#Discover the audience first
bashcurl -s https://gateway.veefore.example.com/.well-known/veefore-platform-key
json{
"key_id": "key_426daf15e177da4a",
"algorithm": "Ed25519",
"public_key": "MCowBQYDK2VwAyEA…",
"format": "spki-base64",
"audience": "https://gateway.veefore.example.com"
}
Unauthenticated, cached for five minutes. Use audience verbatim.
The URL you dial and the gateway's configured public URL are frequently
different text — 127.0.0.1 against localhost, an internal service name
behind a load balancer, a port-forward in development. The check compares
strings, not hosts, and a mismatch is a bare 401.
Publishing the audience is safe: it is the value you must sign for, and knowing it grants nothing without the private key.
#Get a session token
httpPOST /gateway/v1/token
content-type: application/json
{
"agent_id": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"assertion": "eyJ0eXAiOiJhZ2VudG9zLWFzc2VydGlvbit…",
"sdk": { "name": "acme-go-client", "version": "0.3.1", "runtime": "go1.23" }
}
json{
"data": {
"token": "aos_at_…",
"token_type": "Bearer",
"expires_in": 900,
"expires_at": "2026-10-03T12:53:04.000Z",
"agent_id": "agt_01JAV…",
"environment_id": "env_01JAV…",
"organization_id": "org_01JAV…"
}
}
201. Reporting sdk is optional and worth doing — it appears in the session
record, and veefore doctor uses it to flag version drift.
#Handshake instead, if you want the tool list
httpPOST /gateway/v1/handshake
content-type: application/json
{
"agent_id": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"proof": "eyJ0eXAiOiJhZ2VudG9zLWFzc2VydGlvbit…",
"nonce": "YmFzZTY0dXJsLXJhbmRvbQ",
"timestamp": "2026-10-03T12:38:04.000Z",
"sdk": { "name": "acme-go-client", "version": "0.3.1", "runtime": "go1.23" },
"declared_tools": ["refund_customer", "order_lookup"]
}
Note the differences from /token: the field is proof rather than
assertion, nonce and timestamp are repeated at the top level, sdk is
required with all three fields, and the assertion's purpose must be
handshake.
json{
"data": {
"agent_id": "agt_01JAV…",
"session_token": "aos_at_…",
"expires_at": "2026-10-03T12:53:04.000Z",
"mode": "ENFORCE",
"environment": { "id": "env_01JAV…", "name": "Production", "type": "PRODUCTION" },
"organization": { "id": "org_01JAV…", "name": "Acme Payments" },
"authorized_tools": [
{ "key": "refund_customer", "name": "Refund a customer order",
"capability": "refund.create", "status": "ACTIVE" }
],
"capabilities": ["order.read", "refund.create"],
"policy_summary": { "bound_policies": 3, "mode": "ENFORCE" },
"passport_version": 4
}
}
Handshake additionally requires the agent to be ACTIVE, not isolated, and its
environment not paused. /token does not check those — the action pipeline
does.
#Request an action
httpPOST /gateway/v1/actions
authorization: Bearer aos_at_…
content-type: application/json
idempotency-key: refund-ord_8812-attempt-1
{
"tool": "refund_customer",
"capability": "refund.create",
"action": "create",
"parameters": { "orderId": "ord_8812", "reason": "damaged on arrival" },
"amount": 25000,
"currency": "INR",
"destination": "payments.internal.example.com",
"purpose": "Customer reported damage; agent verified photo evidence."
}
The response is the ActionDecision documented in
How it works. Branch on allowed.
Related routes: POST /gateway/v1/actions/retry with approval_id to resume
after an approval, POST /gateway/v1/mcp/call, POST /gateway/v1/a2a/invoke.
#Session handling
| Lifetime | AGENT_TOKEN_TTL_SECONDS, 900 by default |
| Refresh | None. Sign a fresh assertion and get a new token |
| On rotation | Every session dies; old assertions get IDENTITY_GENERATION_STALE |
| On revocation | Every session dies; AGENT_IDENTITY_REVOKED |
There is no refresh token on purpose. A refresh token is a long-lived bearer credential, and the agent already holds something better — the private key. Re-sign.
Treat 401 as "mint a new token and retry once". Treat a second 401 as a real
identity problem and stop.
#Checklist for a new implementation
- [ ]
audiencefetched from/.well-known/veefore-platform-key, not hard-coded - [ ] Payload canonicalised: keys sorted, no whitespace,
undefinedomitted - [ ] Header
JSON.stringify'd, not canonicalised - [ ] Ed25519 over the raw signing-input bytes, not a digest
- [ ] Fresh random nonce every time
- [ ]
expshort — two minutes is plenty - [ ] Correct
purposefor the endpoint - [ ]
403never retried - [ ] Transport errors and
503retried with backoff - [ ] Private key never logged and never in a request body
#Verify against a reference
bashnpx tsx scripts/smoke-sdk.ts
npx tsx scripts/smoke-python-sdk.ts
Both exercise signing and canonicalisation against a live gateway. If your canonical bytes match what those produce for the same payload, your signatures will verify.
tsimport { canonicalJson } from '@veefore/sdk';
console.log(canonicalJson(payload));