AgentOnboard Docs

Error Handling

Every failure code, what causes it, what you should do about it, and the status you return.

Every failure is { ok: false, code, error }. code is the stable thing to branch on — a frozen, published vocabulary that will only ever grow, because you branch on it in production. error is one sentence written to be shown to a human; do not parse it.

The same descriptions ship at runtime, so you can read them out of your own install rather than trusting this table:

import { VERIFY_ERROR_DESCRIPTIONS } from "@agentonboard/sdk";

console.warn(VERIFY_ERROR_DESCRIPTIONS.EXPIRED);

The status you return

The framework bindings apply this mapping, and it is deliberate. If you call the core yourself, apply it yourself.

CodesStatusWhy
EXPIRED, INVALID_SIGNATURE, AUDIENCE_MISMATCH, ISSUER_MISMATCH, MALFORMED_TOKEN, UNKNOWN_KEY, KEY_SOURCE_UNAVAILABLE, MISSING_EMAIL401Uniform on purpose. A missing header, an expired token, a bad signature, the wrong audience, an unknown key, and a key endpoint that is down all mean one thing: "we do not know who you are." Splitting them into 400, 401, 419, and 503 hands anyone probing your endpoint a map of which part of a forgery attempt failed.
ACCOUNT_REQUIRED403The identity is genuine — the token verified — and the account is not. Re-authenticating cannot create an account, so 401 sends the human in a loop and hides the one action that works: connect the account, then retry.
CONFIG_ERROR, RESOLVER_ERROR500Nobody's request is wrong. A 401 tells the agent its token is bad and invites a re-authentication loop against a token that is fine, and it buries a server fault where nobody looks for 500s.

MALFORMED_TOKEN and KEY_SOURCE_UNAVAILABLE are both 401

A token that is not a readable JWT, and a key endpoint that could not be reached, are both about the caller's token as far as the outside world is concerned. Answering 400 for one and 500 for the other would turn your endpoint into an oracle: 400 says "your forgery is malformed", 500 says "we cannot check right now".

The default body is { "code": "...", "error": "..." }, which publishes the code to an unauthenticated caller. That is intentional — it is what lets an agent act without a human interpreting it — and it is also an oracle. onFailure is the documented way to publish something else.


Token verification codes

VerifyErrorCode — nine codes, all reachable from verifyAgentToken and therefore from everything above it.

CodeStatusCauseWhat to do
EXPIRED401exp is in the past beyond the 5-second tolerance. Tokens live 5 minutes, so this is the most common code you will see.Answer 401. An agent should mint a new token and retry once.
INVALID_SIGNATURE401The signature did not verify against a published key, or the token used an algorithm this SDK does not accept — alg: none, or HS256 signed with the public key.Answer 401. The token was not minted by us.
AUDIENCE_MISMATCH401aud is not your normalized audience, or the claim is absent. Almost always a token minted for a different service, or an auth.md advertising the wrong hostname.Answer 401. Check the hostname in your auth.md against the audience in your source.
ISSUER_MISMATCH401iss is not the resolved issuer, or the claim is absent.Answer 401. If you run a staging or self-hosted issuer, set AON_ISSUER or pass issuer — this code is what you get when you configure nothing.
MALFORMED_TOKEN401The token is not a readable compact JWS: garbage, an empty string, two segments, a header that is not JSON. Also a required claim that is absent or unusable.Answer 401. The caller sent something that is not a token.
UNKNOWN_KEY401The header carried no kid, or its kid matched no key in any set this process could obtain. The code deliberately does not say which.Answer 401. If tokens that were valid a second ago now fail this, a rotation is in progress — see production.
KEY_SOURCE_UNAVAILABLE401The key set could not be obtained: the fetch failed, the published set is not one the SDK can verify against, or it publishes more than one usable key for the token's kid.Answer 401, and alert. This is our side, not the caller's. It only fires on a cold cache — a warm process falls back to its last good set.
MISSING_EMAIL401The token verified, and carries no usable email claim.Answer 401. The token is not one of ours.
CONFIG_ERROR500The call itself is wrong: no audience, an audience that does not normalize to a hostname, a jwksUrl that will not parse, a clockToleranceSeconds that is not a non-negative number.Fix the code you wrote. Nobody's request is wrong, so this is a 500.

UNKNOWN_KEY is deliberately ambiguous

It does not tell you whether the key was revoked, rotated away, or never existed, nor whether the token was forged — and it does not tell you whether the set is merely out of date because the key endpoint was unreachable. The SDK cannot know, and a wrong confident diagnosis is worse than none. A forged kid and a rotation look the same from here; both are 401.


Account codes

AccountErrorCode — the nine above plus two, in their own union. ACCOUNT_REQUIRED is not in the core vocabulary precisely because it is not a verification failure: a core consumer's switch over the nine should not have an arm that implies an auth problem.

CodeStatusCauseWhat to do
ACCOUNT_REQUIRED403The token verified. Your resolver returned null or undefined — this human has no account at your service.Answer 403, tell the human which account to connect and where, then retry once. Retrying without the connection step will never succeed, and neither will sending them to register.
RESOLVER_ERROR500Your resolveAccount threw. The thrown value is never echoed back — it is untrusted text that usually carries a connection string, and it would land in your error aggregator.Log the detail inside your own resolver. Answer 500. Do not tell the caller to re-authenticate; the token is fine.

Two resolver values that look like absences

return user?.id ?? null;          // null      → ACCOUNT_REQUIRED
return users.find((u) => u.email === email);  // undefined → ACCOUNT_REQUIRED
return 0;                          // 0         → a real user id, resolves
return "";                         // ""        → a real user id, resolves

Only null and undefined mean "no account". 0 and "" are user ids in the wild, and the SDK resolves them successfully rather than guessing your id scheme.


What to log

Log the code. Never the token, never the Authorization header, never the email.

// Good
console.warn(`Agent auth failed: ${result.code}`);

// Bad — a bearer credential, live for up to 5 minutes, into a log aggregator
console.warn(`Auth failed for ${token}: ${result.error}`);

The jti from a successful verification is the field to correlate on in an audit log. It is not a replay defence: the SDK records no token ids, so a live token presented twice inside its 5-minute lifetime verifies both times. That is what a short-lived bearer token is.


On this page