AgentOnboard Docs

Mapping an Identity to an Account

verifyAgentAccount, the resolver contract, and why an agent may never create or auto-link an account.

Verification answers who is this human. It does not answer do they have an account here. verifyAgentAccount is the seam between the two: it runs verifyAgentToken, then calls a resolver you write, and hands your handler your own user id.

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

const outcome = await verifyAgentAccount({
  token,
  audience: "notes.com",
  resolveAccount: async ({ email }) => {
    const user = await db.user.findUnique({ where: { email: normalizeEmail(email) } });
    return user?.id ?? null;   // null or undefined === no account
  },
});

if (!outcome.ok) {
  // outcome.code is one of the nine core codes, or ACCOUNT_REQUIRED / RESOLVER_ERROR
  return { code: outcome.code, error: outcome.error };
}

// outcome.user is YOUR user id

Every verifyAgentToken option is accepted here, unchanged. This layer adds one callback and changes nothing else.


The result

type AccountOutcome<TUser> =
  | { ok: true;  email: string; user: TUser }
  | { ok: false; code: AccountErrorCode; error: string };

There is no email on the framework bindings' success arm

verifyAgentAccount returns { ok: true, email, user }. The framework bindings return { ok: true, user } only — the email is withheld there, so that the id in a handler is the only identity a query can be scoped by. Code against session.user.

user is whatever your resolver returned: an id, a row, an object. It is yours, and the only thing that crosses into your handler.


The resolver contract

type AccountResolver<TUser> = (
  identity: AgentIdentity
) => TUser | null | undefined | Promise<TUser | null | undefined>;

type AgentIdentity = { readonly email: string };

It receives the verified email and nothing else — not the Request, not the headers, not the cookies, not the IP, not the token, not our internal user id. That is the boundary: identity answers who, and it never answers what they may do.

Four properties, each of which is load-bearing:

Return null OR undefined for 'no account'. A resolver written as users.find(...) returns undefined.

users.find((u) => u.email === email) returns undefined, not null. A type that only allows null rejects the most natural resolver there is — and if you cast it away, your undefined is still read as an absence. Only null and undefined mean "no account".

  • 0 and "" are user ids. They resolve successfully. Do not use a falsy id.
  • It runs exactly once per request, and its result is never cached by this layer. A stale "this user exists" is how a deleted or banned account keeps working. If you want a cache it is yours, in your layer, where you can see it and reason about it.
  • Throwing is safe. It becomes RESOLVER_ERROR — never an exception, never ACCOUNT_REQUIRED — because a throw usually means "my database is down", and reporting that as a missing account sends a human to fix an account that is fine.
resolveAccount: async ({ email }) => {
  // `email` is the claim verbatim, including whitespace and case. Normalize once, here.
  const row = await db.user.findUnique({ where: { email: email.trim().toLowerCase() } });
  return row?.id ?? null;   // undefined is equally fine
},

ACCOUNT_REQUIRED: the rule the whole model rests on

When your resolver returns nothing, you get:

{ "code": "ACCOUNT_REQUIRED", "error": "This identity has no account at your service; send the human to connect their account, then retry." }

This is not a failed verification. The token verified. The identity is genuine. What is missing is an account at your service, and that is not an authentication problem.

Which is why the framework bindings answer it with 403, not 401, unconditionally:

ACCOUNT_REQUIRED is 403. Not 401.

401 means "I do not know who you are", and the client's response to it is to re-authenticate. Re-authenticating cannot create an account. A 401 there hides the one action that does work — the human connects their account, then retries — and sends an agent into a re-authentication loop against a token that is fine.

onFailure: ({ code, error }) =>
  code === "ACCOUNT_REQUIRED"
    ? Response.json(
        { problem: "connect_account", error },
        { status: 403, headers: { location: "https://notes.com/connect" } },
      )
    : Response.json({ code, error }, { status: 401 }),

The full status mapping is on the error page.


The two ways to break it

There is no consent model. Issuance is automatic for any domain, so any AgentOnboard user can present a genuine identity to your service whether or not they have ever heard of it. A token that verifies proves the inbox was checked at AgentOnboard. It does not prove the person is your customer, is not banned, is not already a different account in your database, or consented to anything.

Never auto-provision, and never link by email

Both of these are the abuse path the zero-consent model creates, and both look like helpfulness:

Auto-provisioning turns your service into a writable surface for strangers who have never seen it — spam, scraping, terms-of-service bypass, and support load you cannot attribute.

Linking an incoming identity to an existing account by matching on email is a permanent account-takeover path: whoever controls the inbox at AgentOnboard has demonstrated nothing about owning the account at your service, and the link outlives the session.

The requirement is documented rather than enforced. We cannot enforce it for you. Return nothing for an unknown email and let the human connect their own account.

If you need a service that only serves its existing customers, this model cannot serve you, and that is a design decision rather than a bug in either integration. Gate on the connection, not on the token.


What ACCOUNT_REQUIRED should look like to the human

The error string exists so an agent can read it and act without a human interpreting it. Point it at the flow that lets a person connect an existing account:

  1. Answer 403 with code: "ACCOUNT_REQUIRED".
  2. Say which account they need to connect and where.
  3. The human connects, and the agent retries the same request, once.

A retry without the connection step will never succeed. So must a signup: an agent cannot create an account on your service, and telling it to send the human to register produces an account that the zero-consent model has no reason to trust. Publish the connect flow in your auth.md rejection table — see the auth.md page for the canonical wording.


Next

On this page