Veefore docs

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:

text
base64url(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"
}
substringrequired

The agent id.

audstringrequired

The gateway audience, compared as a string. Fetch it; do not assume it.

purposestringrequired

agent_authentication for /token, handshake for /handshake. They are not interchangeable — an assertion minted for one is refused for the other.

noncestringrequired

Single use. 18 random bytes, base64url, is what the SDKs generate. Reuse is NONCE_REPLAYED.

iatISO-8601required

Issued at, UTC.

expISO-8601required

Expiry. 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
  • undefined values omitted entirely
  • No insignificant whitespace
  • Non-finite numbers become null
  • Date values 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

bash
curl -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

http
POST /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

http
POST /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

http
POST /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

  • [ ] audience fetched from /.well-known/veefore-platform-key, not hard-coded
  • [ ] Payload canonicalised: keys sorted, no whitespace, undefined omitted
  • [ ] Header JSON.stringify'd, not canonicalised
  • [ ] Ed25519 over the raw signing-input bytes, not a digest
  • [ ] Fresh random nonce every time
  • [ ] exp short — two minutes is plenty
  • [ ] Correct purpose for the endpoint
  • [ ] 403 never retried
  • [ ] Transport errors and 503 retried with backoff
  • [ ] Private key never logged and never in a request body

#Verify against a reference

bash
npx 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.

ts
import { canonicalJson } from '@veefore/sdk';
console.log(canonicalJson(payload));

Identity, authority, policy and enforcement for autonomous AI agents.