Skip to main content

Authentication

There are two credentials and they are not interchangeable.

CredentialLivesUsed for
Project API key (pk_…)your server, onlyminting end-user tokens; project administration
End-user token (JWT)your frontend, or your serverevery data-plane call: chat, documents, actions

The rule: your server mints, your client calls. A token carries the permissions, so your frontend never holds anything that could widen them.

Issue a mint-only key

A project key carries a scope, chosen when you create it:

ScopeWhat it can do
mint (default)Mint end-user tokens for this project, and read its capability ceiling. Nothing else.
adminEvery Project API call: the ceiling, the corpus, skills, further keys, deleting the project.

Almost every backend wants mint. It is the difference between a leaked key costing you some tokens and costing you the workspace — an admin key can raise the project's own ceiling and delete everything in it.

An admin key satisfies a mint requirement, never the reverse. A mint key used on an admin route is refused with a 403 that says so. Either way: keep it in a secret store, one per environment, and revoke it in the dashboard the moment it might have leaked.

Minting a token

curl -X POST "https://oberik.com/api/projects/$PROJECT_ID/token" \
-H "X-API-Key: $OBERIK_PROJECT_KEY" \
-H "content-type: application/json" \
-d '{
"subject": "acme:finance:user_9f3c",
"scope": "acme:finance:user_9f3c",
"capabilities": ["chat", "documents:read"],
"expiresIn": 3600
}'
{ "access_token": "eyJ…", "expires_in": 3600, "capabilities": ["chat", "documents:read"] }

Send it as Authorization: Bearer <access_token>.

Fields

FieldMeaning
subjectWho this token is. Owns whatever it creates, and is the default visibility boundary.
scopeWhat it may see — a path prefix. Omit to mean "only this subject's own data".
capabilitiesWhat it may do. Intersected with the project's ceiling; you can narrow, never widen. Modalities are the exception — see below.
roles / groupsLabels used by document ACLs — except three role names that are privilege grants. See below.
modelsRestrict this token to a subset of models.
maxEffortCap reasoning effort.
maxToolIterationsCap agent↔tool loops for this token. Unset means unlimited.
expiresInSeconds. Keep it short and mint per session.

admin, owner and service are not labels

Do not forward your application's own role names

Three role names mean something to the data plane. A token carrying admin, owner or service:

  • reads every end-user's chat sessions in the project, not just its subject's — chat.sessions.list() returns the whole tenant;
  • reads every document and memory regardless of owner (self-visibility is the one exception, which even an admin cannot see);
  • is what ai.audit.list and ai.audit.forget require.

That is the entire per-end-user isolation boundary, switched off by a string. If your backend maps its own users' roles onto this field — and admin is the most likely name to pass straight through — one customer's admin gets every customer's conversations.

Use groups, or any other name, for ACL labels; those carry no privilege. Mint an admin token deliberately, for a back-office caller, and never from a role name that arrived in a request. The mint response returns a warnings array whenever a privileged role was granted, so you can assert on it in a test.

Read warnings on every mint

The same array says when a capability you asked for is not on the token, which is otherwise invisible until something else refuses:

{ "capabilities": ["chat", "input:file", "output:file"],
"warnings": [
"'tasks' is not a capability — did you mean 'tasks:read' or 'tasks:write'? Nothing was
granted for it, and a request needing it will be refused later by whichever call needs it.",
"'computer' was not granted: this project's ceiling does not allow it. Turn it on under
Capabilities in the dashboard, or mint without it — the agent will otherwise simply say
it cannot do the thing, which is not something you can assert on."
] }

Two different mistakes with two different fixes: the first is a spelling, the second is a dashboard toggle. Without them a mistyped name mints happily and the 403 arrives from tasks.create forty seconds later, and a ceiling-trimmed capability shows up only as the agent explaining in prose that it cannot help — which no test can assert on.

Capabilities

A capability is a permission the data plane checks on every call. The project's Capabilities page is the ceiling; a token can hold any subset of it.

List the ones this end-user needs. The modality capabilities — input:<kind> and output:<kind> — are the one thing you don’t have to restate: leave them out and the token carries whatever the project is configured for. They describe what the agent you built can perceive and hand back, which is a property of the project rather than a decision about one user, and reading their absence as a refusal meant an app that listed its tool permissions ended up with an agent that could write a document and not give it to anyone. Name any output:<kind> and you're being explicit: that side narrows to exactly what you listed. The two sides are independent, and neither can exceed the ceiling.

The full set, grouped the way the dashboard groups them. Every one is also declinable per request with the enable_* flag beside it.

CapabilityGrantsPer-request flag
Conversation
chatconversations
system_promptsend a per-request system message (the project's still applies, and wins)
steera message into a running turn
voicetalk to the agent — a live full-duplex call, not a transcript round trip. The per-request flag governs the agent's own conversation controls (hold, resume), not whether a call can be openedenable_voice_control
Knowledge
documents:read / documents:writesearch / upload and manage documents. Setting the project to read-only documents stops documents:write ever being mintedenable_rag
memorydurable memory and the wikienable_memory
Internet
web_searchpublic web search and page readsenable_web_search
browserdrive a real browser — click, type, scroll, screenshotenable_browser
browser_handoffhand a page to the user when only a person can act
Agentic
todothe plan it keeps across a conversationenable_todo
computersandboxed computeenable_computer
subagentswork handed to another agent beside itenable_subagents
tasks:read / tasks:writeschedulingenable_scheduling
triggerswebhook triggers an end-user may create
Human in the loop
ask_userpause on a question and wait for an answerenable_ask_user
approvalsstop before something irreversibleenable_approvals
ui_toolscomponents it draws into your UI
voice:transferon a call, hand the caller to a person — they wait on hold while whoever picks up is briefed, and come back to the agent if nobody does
followupssuggested next messages, generated beside the conversation
recapa one-line "where we got to" for someone returning
auto_titlename each conversation from its first message; titles stay empty otherwise, and a title you set yourself needs no capabilityenable_auto_title
Extensions
webhook_toolsthe agent may call the tools you publish as URLs — they work in unattended runs, where a client-side tool has no clientenable_webhook_tools
mcp:manageend-users may attach their own MCP servers
plugins:writeend-users may upload their own skills. Using the ones you published needs no capability — decline them for a turn with enable_plugins: falseenable_plugins
action_spacerelevance-based tool pre-selection for large catalogsenable_action_space
In and out
input:filea user may attach any file; what the model can't perceive is read as text (OCR, transcript, extraction)
input:image / audio / videothe model may perceive that kind directly, rather than through extraction
output:filethe agent may hand back any file it produced — a document, a sandbox export, a screenshot
output:image / audio / videothe model may generate that kindoutput_modalities

Anything not granted is refused — including a tool the model tries to call anyway. That's the point: prompt injection can't reach a capability you didn't grant.

// A read-only research token.
capabilities: ["chat", "documents:read"]

// Narrow further per request: this turn may only read documents, nothing else.
await ai.chat.send({ message, allowed_tools: ["rag_search"] });
"Read-only" here means read-only, network included

A token like the one above carries the project's modality capabilities too — output:file and output:image are inherited rather than listed (Files & media) — so it can hand back a file the agent produced.

It cannot reach the network. Every tool that fetches something is behind a capability this token does not list: web_search and browse_url behind web_search, the browser family behind browser, and screenshot_url behind web_search as well (it fetches a URL to make the image it returns). That last one used to be gated on the output modality alone, which meant this exact token could read an external page — details.

allowed_tools still narrows further per request when you want a capability granted and one tool withheld.

Scopes: isolating users inside one project

scope is a path prefix, with : as the separator. A token sees its own subtree.

subjectscopeSees
acme:fin:anaacme:fin:anaonly Ana's own documents and sessions
acme:fin:leadacme:fineverything in the finance team
acme:adminacmethe whole customer

Use one project per customer for hard isolation, and scopes for the hierarchy inside that customer.

Rotation and expiry

Mint a token per user session with a short expiresIn. Rather than handing the SDK a static token and dealing with expiry at every call site, give it getToken — the SDK calls it back with { expired: true } when the current token is finished with, replays the request with the new one, and your code never sees the 401:

const ai = createClient({
getToken: async ({ expired }) => {
if (!expired && cached) return cached; // your own cache
cached = await mintFromYourBackend(); // the endpoint above
return cached;
},
});

This is the recommended way to authenticate. Details worth knowing:

  • Two things call you back with { expired: true }, and the second is the one that surprises people. The first is a 401 — the server rejected the token. The second is a local check: if the token you returned expires within the next 60 seconds, the SDK asks for another one before sending anything. So you will see { expired: true } with no request having failed. That is a 401 avoided rather than recovered from.
  • The check is on the token's remaining life, not on the expiresIn you minted it with. A short-lived token trips it straight after minting; a long-lived cached one trips it in its final minute — which is the recommended shape working as intended, not a problem. There is no TTL for which it never fires.
  • Either way it costs one extra mint per client, not per request. If the replacement is also inside the margin (a project whose whole TTL is under a minute), the SDK stops checking ahead for that client rather than minting on every call, and expiry goes back to being handled by the 401.
  • Concurrent requests share one refresh, so a page that fires five calls at once doesn't mint five tokens.
  • A request is replayed at most once, so a token that's still rejected surfaces as a normal 401 instead of looping.
  • An interrupted stream re-attaches to the same run with the new token — an answer in flight is neither lost nor paid for twice.
  • If your callback throws, the original 401 is what you get, not the callback's error.

A static token still works and is fine for a script or a server job shorter than the TTL — but nothing can rescue it once it expires.

Narrowing a token you already hold

A backend that already has a project credential can mint a narrower one without going back to the control plane:

const { access_token } = await ai.auth.token({
user_ref: user.id,
capabilities: ["chat", "documents:read"],
scope: `${user.orgId}:${user.id}`,
allowed_models: ["openai/gpt-4o-mini"],
max_effort: "low",
expires_in: 900,
});

Every field here is intersected or clamped against what the calling credential holds, so a restricted token cannot mint a broader one. That covers what the child may do: capabilities, scope, data_scope, allowed_models, max_effort, the tool-iteration and context ceilings, and the subject it may be minted for — a bound the caller holds is applied whether or not you mention the field, because an absent claim reads as unrestricted.

expires_in is the exception, and it is deliberate. It is clamped against the project's maximum rather than the caller's remaining lifetime, so a 10-minute token can mint a 24-hour one. The child holds no more privilege than its parent — it is the same grant, for longer — and a short-lived credential refreshing itself into a session is a pattern worth keeping. Size a token's lifetime by what it may do, not by the lifetime of whatever minted it.

allowed_models narrows within the project's own registrations; it is not what keeps a token inside them. A request may only name a model this project registered, whatever the token says:

model: "gpt-4o" // on a project that registered only "hukuk-db"
400 model 'gpt-4o' is not available to this project. Registered: hukuk-db

That boundary is checked before the request goes anywhere, so a name the project never configured cannot reach a shared deployment, cannot spend on anything but the project's own key, and cannot escape its cap. Use allowed_models to narrow further — pinning a browser token to your cheapest model — rather than as the thing standing between projects.

Project API keys are shown once at creation and can be revoked from the dashboard. Revoking one immediately stops new tokens being minted with it; already-minted tokens expire on their own schedule.

Never put a project key in a browser

It can mint tokens with any capability the project allows. If one leaks, revoke it in the dashboard and create a new one.