Skip to content

🧪 Beta — Available today; the interface contract may still change.

Your first delegation in 30 minutes

By the end of this guide you will have:

  • A Delegate Token issued because the user approved it in person — not a password, not a long-lived API key, but a short-lived, attenuated, revocable slice of authority
  • One complete verify-and-exchange round: confirm the token is valid, then exchange it for a product access token
  • A traceable audit chain auditId — who granted it, what they granted, for how long, all on the record

Before you start: two things to get straight

① Why domains and endpoints say eak and not genauth: GenAuth is the identity layer of Qoni. The SDK, the Console, and access keys share the same Qoni entry points (domains and endpoints keep the legacy identifier eak) — @eazo/anima, dashboard.qoni.ai, and /api/v3/eak/... are house numbers on the same building (details in What is GenAuth).

② What stands in for "the resource being accessed": so you can run the whole chain in 30 minutes, the example calls the web search capability that ships with Qoni (scopes look like webagent.web_search:run), which means you don't have to change anything first. To put delegation in front of your own API, the chain is identical and only the target resource changes — see Let an agent call your APIs on behalf of a user and Protect your APIs.

Step 0 — Get an access key (3 min)

  1. Open your workspace at dashboard.qoni.ai
  2. Credentials → Create, and configure two allowlists:
    • allowedScopes: the ceiling on which scopes this key may delegate. This tutorial needs webagent.web_search:run and webagent.web_search:read, so include at least those two — set it narrower than what you request later and the request is rejected outright.
    • allowedAgents: the agent identifiers you allow delegation to. This tutorial uses report-agent, so put it in.
  3. Copy the AccessKey / SecretKey. The secret is shown once. Lose it and your only option is to rotate and recreate
  4. Register your callback URL (redirectUri) in the same place; this tutorial uses http://localhost:3000/qoni/callback
bash
export ANIMA_ACCESS_KEY=ak_demo_xxxxxxxxxxxxxxxx
export ANIMA_SECRET_KEY=sk_demo_xxxxxxxxxxxxxxxx

Keys belong on the server and nowhere else

The AK/SK never goes into a browser, an app, or any client-side code. It can start delegations on behalf of your whole organization — storage requirements are in Security considerations.

Where the agent identifier (agent) comes from

This tutorial runs on the bare string report-agent, which works as long as it is in the allowedAgents list from the previous step. In production, an agent should be registered into the ledger first — with a description, an Owner, and a Sponsor — so the identifier maps to a registration record. See The Agent Identity model.

Step 1 — Install the SDK (30 sec)

bash
npm install @eazo/anima
bash
# Nothing to install. But calling HTTP directly means building the AK/SK-signed
# Authorization header yourself; the cURL snippets here use <AK/SK signature> as a placeholder.
# To go direct: generate it with the SDK's exported buildStringToSign / buildSignature /
# buildAuthorization functions, or get it working with the SDK first and migrate later.
# See h2-sdk-reference and h1-api-reference.

The cURL snippets illustrate the contract; they are not paste-and-run commands

The signing algorithm is not covered on this page. To get running in 30 minutes, take the TypeScript path; use the cURL snippets to check the HTTP contract against.

interactive mode: you make the request on the agent's behalf, and it counts only when the user says so.

typescript
import { Qoni, AnimaScopes } from "@eazo/anima";

const anima = new Qoni({
  host: "https://api.eak.eazo.ai",
  accessKey: process.env.ANIMA_ACCESS_KEY!,
  secretKey: process.env.ANIMA_SECRET_KEY!,
});

const { data } = await anima.delegateToken({
  mode: "interactive",
  agent: "report-agent",
  scopes: [AnimaScopes.WEB_SEARCH_RUN, AnimaScopes.WEB_SEARCH_READ],
  redirectUri: "http://localhost:3000/qoni/callback",
  state: "demo-state-001",
  user: { id: "usr_demo_0001" },
});

console.log(data.authorizationUrl); // send the user here
console.log(data.grantId);          // you need this in the callback
bash
curl -X POST https://api.eak.eazo.ai/api/v3/eak/delegations \
  -H "Authorization: <AK/SK signature>" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "interactive",
    "agent": "report-agent",
    "scopes": ["webagent.web_search:run", "webagent.web_search:read"],
    "redirectUri": "http://localhost:3000/qoni/callback",
    "state": "demo-state-001",
    "userId": "usr_demo_0001"
  }'

The authorizationUrl in the response is the entry point to the consent page. Scopes use the service.capability:action format — this example asks for running and reading web search, and nothing else.

Two places people get stuck

redirectUri must match what you registered in Step 0, or the request is rejected. For local development, http://localhost:3000/... is fine — you do not need public HTTPS.

What goes in user.id: in production, the ID of the currently signed-in user (resolve it from that user's access token with anima.currentUser({ accessToken })). For a shortcut while following this tutorial, await anima.resolveAnyBoundUser() pulls a real user ID from the bound user pool — that method is for demos and smoke tests only.

How the two spellings line up

The SDK takes user: { id }; the HTTP contract names the field userId — two expressions of the same thing. The SDK also accepts a top-level userId, but it is deprecated, so write user: { id } in new code (see SDK Reference).

Step 3 — The user approves, you exchange for a Delegate Token (10 min)

The user opens authorizationUrl, signs in, and sees the consent page: which agent, which permissions, for how long. Once they approve, the browser bounces back to your redirectUri with a one-time code. Finish the exchange in your callback handler:

typescript
// Your callback route (Express below; other frameworks read the query much the same way)
// GET /eak/callback?grantId=...&code=...&state=...
app.get("/eak/callback", async (req, res) => {
  const { grantId, code, state } = req.query as Record<string, string>;

  // Check state first: only continue if it matches the value you sent
  if (state !== "demo-state-001") return res.status(400).send("state mismatch");

  const { data: grant } = await anima.completeDelegateToken({ grantId, code, state });

  console.log(grant.token);         // the Delegate Token
  console.log(grant.expiresIn);     // lifetime, in seconds
  console.log(grant.auditId);       // audit chain ID — every step from here is on the record
  console.log(grant.grantedScopes); // the scopes actually granted

  res.send("Authorized");
});
bash
curl -X POST https://api.eak.eazo.ai/api/v3/eak/delegations/complete \
  -H "Authorization: <AK/SK signature>" \
  -H "Content-Type: application/json" \
  -d '{ "grantId": "grant_xxx", "code": "code_xxx", "state": "demo-state-001" }'

The code is single-use

The authorization callback code dies the moment it is consumed; replaying it fails outright. The token you get back is the thing you use from here on.

Step 4 — Verify and exchange (8 min)

First confirm the token is genuinely valid — introspect echoes back every fact about the grant:

typescript
const { data: info } = await anima.genauth.introspectDelegationToken({
  token: grant.token,
});
// { active: true, sub: "usr_demo_0001", agent_id: "report-agent",
//   scope: ["webagent.web_search:run", ...], grant_id, audit_id, ... }

Why introspect returns snake_case

introspect echoes the claims inside the token, and claims follow the JWT convention of snake_case (agent_id, audit_id). SDK method return values are camelCase (auditId, grantedScopes). Seeing both spellings is not a typo: camelCase = SDK return values, snake_case = token claims (full field table in Token and claim reference).

A Delegate Token never reaches a resource directly — its audience is nailed to the token exchange endpoint, so pointing it at a resource gets it rejected. The agent has to exchange it first for an access token for the target resource (RFC 8693 semantics). That hop earns its keep two ways: every exchange is a live ruling (is the token expired, is the key disabled, does the scope overreach), and the token that comes out is valid for one resource only — steal it and it opens no other doors.

typescript
const { data: exchanged } = await anima.request<{
  token: string;
  tokenType: string;
  expiresIn?: number;
}>({
  method: "POST",
  path: "/api/v3/eak/token-exchange",
  body: {
    subjectToken: grant.token,
    resource: "webagent",
    scopes: [AnimaScopes.WEB_SEARCH_RUN],
  },
});

console.log(exchanged.token); // hand this to the agent to call the resource
bash
curl -X POST https://api.eak.eazo.ai/api/v3/eak/token-exchange \
  -H "Authorization: <AK/SK signature>" \
  -H "Content-Type: application/json" \
  -d '{
    "subjectToken": "<delegate token>",
    "resource": "webagent",
    "scopes": ["webagent.web_search:run"]
  }'
# → { "token": "...", "tokenType": "Bearer", "expiresIn": ... }

In the access token you get back, sub is still the user and act identifies the agent — "on whose behalf" and "who is acting" stay clearly separated inside the token (field details in Token and claim reference). Hand it to the agent, let it call the matching product, and the task is running.

What happens next

  • Tokens expire: expiresIn ends it on the dot (60 seconds to 24 hours, whatever you asked for). For task-level delegation, keep the lifetime short.
  • Revocable at any time: for the layers of revocation and how fast each takes effect, see Revocation and emergency response.
  • Queryable throughout: take the auditId to Audit and accountability chain.

Trusted server-side integration (no per-task user consent)

Your app already has a login system and you want to issue tokens for users in the organization's name? Take the silent path backed by organization-level consent — but first read Integrate your existing authentication system and the hard-constraint list in Security considerations.

Next steps