An agent served over HTTP needs to know two things about each request: may it
call at all, and who is it. @lousho/build-ai-agent/auth answers both. You
give a route an ordered list of auth entries (jwt(), oidc(), basic(),
apiToken(), anonymous() or your own function); the first entry that
accepts the request returns a principal, and that principal goes into the
run, where your model / instructions / tools functions and memory scopes
can read it.
import { createAgent, createRouteHandler } from '@lousho/build-ai-agent';
import { apiToken, jwt } from '@lousho/build-ai-agent/auth';
import { mockModel } from '@lousho/build-ai-agent/testing';
const agent = createAgent({
provider: mockModel(['Hello!']),
instructions: ({ principal }) => `You are helping ${principal?.id ?? 'a guest'}.`,
});
export const { GET, POST } = createRouteHandler(agent, {
auth: [
jwt({ secret: process.env.JWT_SECRET ?? 'a-development-secret-of-32-bytes!!', issuer: 'https://auth.example.com', audience: 'agent-api' }),
apiToken(process.env.CI_TOKEN ?? 'ci-token', { id: 'ci' }),
],
});Entries run in order, and each one does one of three things:
| An entry ... | Then |
|---|---|
returns a Principal |
the request is accepted; later entries are not asked |
returns null or undefined |
it skips: the next entry is asked |
throws AuthError(401) or AuthError(403) |
the request is refused with that status; later entries are not asked |
When every entry skips, the request gets a 401. An empty list refuses every
request. An entry that throws anything else is a bug: the request gets a 500
with a generic body, and the error is logged with console.error (never sent).
The built-in helpers never throw for a bad credential: a wrong password, an
expired token or a token for another audience all skip, so the next entry gets
its chance (two jwt() entries for two issuers work), and a request nobody
accepts ends in the same 401.
A Principal is:
interface Principal {
id: string; // JWT `sub`, Basic user name, 'api-token', ...
type: 'user' | 'service';
authenticator: string; // 'jwt' | 'oidc' | 'basic' | 'api-token' | 'anonymous' | your name
issuer?: string; // JWT `iss`: the same id from another issuer is another caller
claims?: Readonly<Record<string, unknown>>; // verified claims; never the raw token or a password
}All helpers are created once, at start-up, and check their options then: a
helper that cannot work throws LOUSHO_AUTH_CONFIG_INVALID
instead of refusing every request later.
Accepts Authorization: Bearer <JWT> signed by the key you configure. Give
exactly one key source:
| Option | Key | Algorithms (default: all of the key's family) |
|---|---|---|
secret |
an HMAC secret of at least 32 bytes | HS256, HS384, HS512 |
publicKey |
a PEM -----BEGIN PUBLIC KEY----- or a JWK |
RS256, RS384, RS512 (RSA, 2048 bits or more), ES256 (P-256), ES384 (P-384) |
jwksUrl |
a JWKS endpoint (https, or http on localhost) | RS256, ES256 by default; RSA and EC keys only |
import { jwt } from '@lousho/build-ai-agent/auth';
const fromOurAuthServer = jwt({
jwksUrl: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com',
audience: 'agent-api',
// Map the verified claims to the principal; return null to skip.
principal: (claims) => (typeof claims.sub === 'string' ? { id: claims.sub, type: 'user', authenticator: 'jwt', issuer: claims.iss, claims } : null),
});What is checked:
- The header's
algmust be one ofalgorithms;noneis always refused, and so is a token whose header marks an extensioncrit. - The algorithm must fit the key: a public key is never used as an HMAC secret, whatever the token's header says, and a key set never supplies an HMAC key.
expis required;nbfandiatare honored. All three allowclockToleranceSecof clock skew (default 60, at most 300).issmust equal one ofissuerexactly (no trailing-slash tolerance), when you setissuer.aud(a string or a list) must contain one ofaudience.audienceis required; passallowAnyAudience: trueto skip that check on purpose.- A key set is fetched with a 5-second timeout, cached for 10 minutes, and
refetched at most once every 30 seconds when a token names a
kidit does not know (a failed fetch is retried on the same 30-second bound). Its URL is configuration only: thejku,x5uandjwkheaders of a token are ignored.
The default principal is { id: sub, type: 'user', authenticator: 'jwt', issuer: iss, claims };
a token without sub skips.
A jwt() whose keys come from an OpenID Connect provider. It fetches
<issuer>/.well-known/openid-configuration once (or discoveryUrl), requires
the document's issuer to equal yours exactly, and uses its jwks_uri (https,
or http on localhost / 127.0.0.1 / [::1] — the same rule as jwt()'s
jwksUrl). Algorithms default to RS256 and ES256; HMAC is not allowed.
import { oidc } from '@lousho/build-ai-agent/auth';
const google = oidc({ issuer: 'https://accounts.google.com', audience: process.env.GOOGLE_CLIENT_ID ?? 'client-id' });The principal has authenticator: 'oidc' and the provider's iss as issuer.
HTTP Basic credentials. User names and passwords are NFC-normalized and
compared in constant time, and every configured user is compared on every
request, so an unknown user takes as long as a wrong password. The 401
carries WWW-Authenticate: Basic realm="<realm>", charset="UTF-8" so browsers
show their login prompt.
import { basic } from '@lousho/build-ai-agent/auth';
const operators = basic({ users: { ops: process.env.OPS_PASSWORD ?? 'change-me' }, realm: 'agent' });
// Or check against your own store (compare in constant time there):
const fromDatabase = basic({ users: async (user, password) => user === 'svc' && password === (process.env.SVC_PASSWORD ?? '') });Only use basic() over HTTPS: the password travels with every request.
apiToken(token, { id }) accepts Authorization: Bearer <token> (compared in
constant time) as the service principal { id: id ?? 'api-token', type: 'service', authenticator: 'api-token' }.
It is what a token string has always meant: createRouteHandler({ auth: '<token>' })
and LOUSHO_API_TOKEN are apiToken(). An empty string (auth: '', e.g. an
env var that is set but blank) is refused with LOUSHO_AUTH_CONFIG_INVALID
by createRouteHandler() when it is created and by serveFetch() on every
request: it neither locks nor opens the routes silently.
anonymous() accepts everyone as { id: 'anonymous', type: 'user', authenticator: 'anonymous' }:
put it last for routes that serve signed-in and anonymous callers, and read
principal.authenticator in the run.
Any function of the Request is an entry. Declare the challenges it answers a
401 with, or it advertises Bearer:
import { AuthError, type AuthFn } from '@lousho/build-ai-agent/auth';
const tenantKey: AuthFn = async (request) => {
const key = request.headers.get('x-tenant-key');
if (!key) return null; // not ours: ask the next entry
if (key === 'suspended-tenant') throw new AuthError(403); // stop here
return { id: key, type: 'service', authenticator: 'tenant-key' };
};
tenantKey.challenges = [{ scheme: 'Bearer', realm: 'tenants' }];- Nobody accepted the request (or an entry threw
AuthError(401)):401with{ "error": "Unauthorized" }and oneWWW-Authenticateheader per distinct challenge of the list, in order (Bearerforjwt,oidc,apiToken;Basic realm="..."forbasic). - An entry threw
AuthError(403):403with{ "error": "Forbidden" }. - An entry failed in another way:
500with{ "error": "Internal Server Error" }.
The body never says which check failed: an expired token, a bad signature and
a wrong audience all get the same 401. Every auth response carries
Cache-Control: no-store. Tokens and passwords are never logged.
| Helper or server | Node 22+ | Cloudflare Workers, Vercel Edge, Bun, Deno |
|---|---|---|
jwt(), oidc(), basic(), apiToken(), anonymous(), routeAuth() |
yes | yes: Web Crypto and fetch only, no node: import |
createRouteHandler({ auth }) |
yes | yes |
The node-server and docker targets |
yes | no (Node http) |
An agent directory's auth.ts |
yes (node-server, docker) |
no |
The cloudflare-worker build target |
LOUSHO_API_TOKEN only; no auth list yet |
oidc() and jwt({ jwksUrl }) need outbound fetch to the key set, which
every listed runtime has. In a hand-written Worker, call createRouteHandler
or routeAuth() yourself with any helper.
auth takes the list, one entry, a token string, or (as before) a function
that returns a boolean. true accepts with the principal
{ id: 'anonymous', type: 'user', authenticator: 'custom' }, false is a
401. Without auth the route is open, and in production
(NODE_ENV=production) it logs one warning. GET <basePath>/health is never
behind auth. The accepted principal reaches every route: POST <basePath>,
/chat, approvals and the useChat endpoint. See Next.js.
The generated server (it uses the bundle's internal createDeployedServer()) takes the same list (or one entry).
When LOUSHO_API_TOKEN is set, apiToken(LOUSHO_API_TOKEN) is appended after
your entries, so an operator's token keeps working. Channels under
/channels/<name> are not behind the list: they verify their own requests.
In an agent directory, put the list in auth.ts (or
auth.js), next to agent.ts:
// my-agent/auth.ts
import { apiToken, oidc } from '@lousho/build-ai-agent/auth';
export default [
oidc({ issuer: 'https://login.example.com', audience: 'agent-api' }),
apiToken(process.env.CI_TOKEN!, { id: 'ci' }),
];lousho build --target=node-server (or docker) bundles it, and the built
server uses it (resolveAgentDir() returns it as auth; the manifest says
auth: true). With an auth.ts, a token baked in with
adapter.scaffold(..., { auth: { token } }) is not used; LOUSHO_API_TOKEN
still is. The cloudflare-worker target serves spec files only and keeps the
LOUSHO_API_TOKEN token; see Deployment.
auth.ts may also export functions named authorizeSession and
authorizeApproval, next to its default export. The built server uses them as
the rules of Who may use a session and
Who may decide an approval, and
resolveAgentDir() returns them under those names. Any other value under
those names fails the load (LOUSHO_AGENT_DIR_INVALID). lousho dev has no
route auth, so neither the list nor these rules apply there.
The principal is on the run context next to sessionId and metadata. The
vendor/ model prefix chooses the provider; with OpenRouter use
openrouter/<vendor>/<model> (e.g. openrouter/openai/gpt-4o-mini).
import { createAgent, defineMemory, inMemoryMemory, type Principal } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
const notes = defineMemory({
name: 'notes',
// One memory per caller. Key on the issuer too: ids from different issuers can collide.
scope: ({ principal }) => principal && `user:${principal.issuer ?? ''}:${principal.id}`,
provider: inMemoryMemory(),
});
const agent = createAgent({
provider: mockModel(['Hi!', 'Hi again!']), // one scripted reply per turn below
model: ({ principal }) => (principal?.claims?.plan === 'pro' ? 'openai/gpt-4o' : 'openai/gpt-4o-mini'),
instructions: ({ principal }) => (principal?.type === 'service' ? 'Answer in JSON.' : 'Be friendly.'),
memory: [notes],
});
// Outside a route, pass the principal yourself:
const caller: Principal = { id: 'u-42', type: 'user', authenticator: 'jwt', issuer: 'https://auth.example.com' };
await agent.send('Hello', { principal: caller });
await agent.session({ id: 'chat-1' }).send('Hello again', { principal: caller });principal is separate from metadata on purpose: metadata is whatever the
caller sent, principal is what auth verified. A run that is resumed from its
checkpoint or after an approval keeps the principal it started with. The Slack,
Discord, Teams, GitHub and Telegram channels set the sender as the principal
(authenticator: 'slack', 'discord', ...). Tools, approval policies,
permission rules and sub-agents see it too: see
Principals in tools and approvals.
- Sessions belong to the caller that started them by default. Once route
auth accepted a caller, a session id is bound to the first principal that
sends it a turn (reading an id claims nothing), and any other principal gets
403(LOUSHO_SESSION_FORBIDDEN). That record lives in memory, per agent, for the 100,000 most recently used sessions. A stored session whose owner is not on record (after a restart, on a second replica, past that cap) is refused to every principal, its owner too. With more than one instance, or a store that outlives the process, passauthorizeSessionthat checks ownership against your own data. See Who may use a session. - Cookies need CSRF protection, which the routes give by default. A POST
must be JSON (
415otherwise) and must not come from another site's page (403,LOUSHO_REQUEST_FORBIDDEN). Keep your session cookieSameSite=LaxorStrictas well. See Cross-site requests. - Approvals. A decision only reaches an approval of the session in the URL
(another session's approval is a
404). By default only the caller the run acts for may decide it. If an approval must come from someone else, such as a supervisor approving a customer's refund, setauthorizeApproval. Otherwise the customer can approve their own call: the approval id is in their own stream. See Who may decide an approval. - Errors. The session routes send an error's
nameandcodewith a generic message. The full error (a provider's response body, a chat template) goes to the server log (console.error) and toonEvent. PassexposeErrors: trueto send the raw message, aslousho devdoes. A missing provider package (LOUSHO_PEER_MISSING) always sends its message: the SDK's ownnpm installcommand. basic()only over HTTPS; a bearer token is a password too, so terminate TLS in front of any server that is not on localhost.- Keep
issuerandaudienceset forjwt(): a token minted for another of your services is otherwise accepted here.
Everything that decides or acts on the caller's behalf gets the run's principal, frozen (a tool or a policy that assigns to it throws):
| Where | How |
|---|---|
A tool's execute(args, ctx) |
ctx.principal |
A needsApproval function |
ctx.principal (its second argument) |
A permission rule's when(args, ctx) |
ctx.principal |
preToolCall / postToolCall hooks |
ctx.principal |
onPermissionDecision(entry, { principal }) |
the second argument; the entry itself carries none |
A pending approval (agent.approvals.list(), approve, a channel's approvers function) |
request.principal: whose call it is |
In-process sub-agents (task, background tasks) |
the lead's principal, everywhere above |
A run without a principal (no route auth, send() without principal) has
none in any of these places.
import { defineTool } from '@lousho/build-ai-agent';
import { z } from 'zod';
export const myOrders = defineTool({
name: 'my_orders',
description: "Lists the caller's orders",
input: z.object({}),
execute: async (_args, ctx) => {
if (!ctx.principal) return { error: 'Sign in first.' };
// Key on the issuer too: the same id from another issuer is another caller.
return { owner: `${ctx.principal.issuer ?? ''}:${ctx.principal.id}`, orders: [] };
},
});The principal comes only from route auth or a channel's verified sender, never from the request body or the model, and a tool cannot change it.
import { createAgent, defineTool } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
import { z } from 'zod';
const deploy = defineTool({
name: 'deploy',
description: 'Deploys a service',
input: z.object({ service: z.string() }),
// Ask a human only when the caller has no `admin` claim.
needsApproval: (_args, ctx) => ctx.principal?.claims?.admin !== true,
execute: async ({ service }) => `deployed ${service}`,
});
const agent = createAgent({
provider: mockModel(['Ready.']),
tools: [deploy],
// Service accounts (CI tokens) may not deploy at all.
permissions: [{ tool: 'deploy', when: (_args, ctx) => ctx.principal?.type === 'service', action: 'deny', reason: 'Not for service accounts' }],
});
await agent.send('Deploy the API', { principal: { id: 'ci', type: 'service', authenticator: 'api-token' } });A decision can name who made it. The approved tool sees the decider as
ctx.approval.by, next to ctx.approval.note:
import { createAgent, defineTool, type Principal } from '@lousho/build-ai-agent';
import { mockModel } from '@lousho/build-ai-agent/testing';
import { z } from 'zod';
const sendEmail = defineTool({
name: 'send_email',
description: 'Sends an email',
input: z.object({ to: z.string() }),
needsApproval: true,
execute: async ({ to }, ctx) => `sent to ${to} for ${ctx.principal?.id}, approved by ${ctx.approval?.by?.id}`,
});
const agent = createAgent({
provider: mockModel([{ toolCalls: [{ name: 'send_email', args: { to: 'sam@example.com' } }] }, 'Sent.']),
tools: [sendEmail],
});
const alice: Principal = { id: 'alice', type: 'user', authenticator: 'jwt' };
const lead: Principal = { id: 'lead', type: 'user', authenticator: 'jwt' };
const paused = await agent.send('Email Sam', { principal: alice });
// The tool runs for alice; lead is recorded as the approver.
await agent.approvals.resolve({ id: paused.approvalId!, approved: true }, { principal: lead });resolve(), answer(), streamResolve() and streamAnswer() take
{ principal }. The approvals route (POST /chat/:sessionId/approvals/:id)
passes the caller its auth list accepted; body fields cannot set it.
mountChannels() passes the user who clicked as
{ id, type: 'user', authenticator: <channel name> }, or, for a question
answered with a message, that message's sender. Decisions made by an approve
callback, or posted to /channels/<name>/approvals/:id, have no approver.
The approver never becomes the run's principal: the approved call and the rest of the run act for the caller that paused it. A credential or a grant that belongs to that caller is used only for that caller's calls, whoever approves.
By default, once route auth accepted a caller, a session belongs to the first
principal that sends it a turn (principals are compared by id, type,
authenticator and issuer). Another principal gets 403
(LOUSHO_SESSION_FORBIDDEN) on GET /chat/:id, POST /chat, POST <basePath> and the useChat endpoint; decisions on its approvals are left to
authorizeApproval (below). Reading an id nobody has sent a turn to yet
claims nothing, so a caller cannot lock a user out of a predictable id
(user-42) by reading it first. A turn with an empty input is a 400 and
claims nothing, and a turn that stores nothing (it failed before saving) drops
its claim. A route without auth checks nothing. All callers of one
apiToken() are one principal, so they share every session; a route guarded
by one bearer token (auth: '<token>', the node server or the Worker with
only LOUSHO_API_TOKEN) checks no owner at all.
The owner is remembered in memory, per agent object, for the 100,000 most
recently used sessions. A restart, a second replica, a session unused for
longer, or a host that builds its agent per request forgets it. The default
then fails closed: a session that is already stored (it has a transcript or an
unfinished turn) but has no owner on record is refused to every principal,
including the one that started it, so nobody can take over a session by
getting its owner forgotten. Only a session that was never stored can be
claimed again. So for anything that runs on more than one instance, or keeps
sessions across restarts, check ownership yourself with authorizeSession.
createRouteHandler() and the generated node server take authorizeSession,
which replaces that default (in an agent directory, export it from auth.ts:
see the example under Who may decide an approval). It is asked before a caller reads a session
(action: 'read'), sends it a turn ('chat', also POST <basePath> and the
useChat endpoint) or decides one of its approvals ('approve'). Return
false for a 403 (LOUSHO_SESSION_FORBIDDEN). Deriving the id from the
caller keeps the check stateless:
import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent';
import { jwt } from '@lousho/build-ai-agent/auth';
const agent = createAgent({ model: 'openai/gpt-4o-mini', store: memoryStore() });
// The user id a session id starts with: everything before its last `-`. A prefix match would let
// `alice` reach `alice-x`'s sessions, so the suffix has no `-` (e.g. `crypto.randomUUID().replaceAll('-', '')`).
const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0));
export const { GET, POST } = createRouteHandler(agent, {
auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://auth.example.com', audience: 'agent-api' }),
// Session ids are `<user id>-<suffix without '-'>`: a user can only reach their own.
authorizeSession: ({ principal, sessionId }) => principal !== undefined && ownerOf(sessionId) === principal.id,
});authorizeSession: () => true lets every authenticated caller use every
session (the behavior before this default).
A browser sends cookies with requests that another site's page makes, so with
cookie-based auth a page on evil.example could otherwise start a turn or
approve a call as your user. The session routes refuse that by default:
- a POST whose
Originis not the server's own (itsHost, or the host a proxy in front reports inX-Forwarded-HostorForwarded: host=) and not inallowedOrigins, or that the browser markedSec-Fetch-Site: cross-site, is403(LOUSHO_REQUEST_FORBIDDEN). Requests without anOrigin(curl, a server, another agent) are not affected; - a POST body must be
Content-Type: application/json(415,LOUSHO_UNSUPPORTED_MEDIA_TYPEotherwise): forms andtext/plainare what another site can send without a CORS preflight.
A front end on another origin is listed in allowedOrigins (it also needs
CORS headers, which your host adds); allowedOrigins: ['*'] turns the origin
check off and requireJsonBody: false the content-type one. Entries are
compared as origins: case, a trailing slash and a default port
(https://app.example:443) do not matter. allowedHosts (off by default)
refuses any request whose Host is not listed, against DNS rebinding of a
server on a private address. All three work on createRouteHandler() and
the generated node server; that server and the Cloudflare Worker read
them from LOUSHO_ALLOWED_ORIGINS and LOUSHO_ALLOWED_HOSTS (comma-separated)
and LOUSHO_REQUIRE_JSON_BODY=false.
Behind a reverse proxy:
- The proxy must tell the server the public host, or a browser's same-site
POST is
403: keep the originalHost(nginxproxy_set_header Host $host;), or setX-Forwarded-Host(proxy_set_header X-Forwarded-Host $host;) orForwarded: host=.... A default port in it (agent.example:443) matches anhttps://agent.examplepage. Otherwise list the public origin inallowedOrigins. allowedHostschecks only theHostheader the server receives, never the forwarded ones (a page that rebinds DNS can set those). Behind a proxy that rewritesHost, list the upstream name the proxy sends, not the public one.
import { createAgent, createRouteHandler } from '@lousho/build-ai-agent';
const agent = createAgent({ model: 'openai/gpt-4o-mini' });
export const { GET, POST } = createRouteHandler(agent, {
auth: process.env.AGENT_TOKEN,
allowedOrigins: ['https://app.example.com'],
});A decision posted to POST /chat/:sessionId/approvals/:id must name the
session the approval belongs to; any other session answers 404, as for an
approval that was already decided. POST <basePath>/approvals/:id (the route
useLoushoAgent posts to) names no session, so it is checked against the
approval's own session.
Then authorizeApproval({ principal, sessionId, approval }) decides; false
is a 403 (LOUSHO_APPROVAL_FORBIDDEN). The default, callerOwnsApproval,
lets only the caller the paused run acts for decide (approval.principal,
compared by id, type, authenticator and issuer). A user can confirm
their own call, and cannot decide another user's. A run without a principal,
or a route without auth, accepts any caller.
When needsApproval means that someone else must approve, say who. Here a
customer can no longer approve their own refund, and a supervisor can read any
session (to see what they are approving) and decide in it:
import { createAgent, createRouteHandler, memoryStore } from '@lousho/build-ai-agent';
import { jwt } from '@lousho/build-ai-agent/auth';
const agent = createAgent({ model: 'openai/gpt-4o-mini', store: memoryStore() });
const isSupervisor = (principal?: { claims?: Readonly<Record<string, unknown>> }) => principal?.claims?.role === 'supervisor';
// Session ids are `<user id>-<suffix without '-'>`; the owner is everything before the last `-`.
const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0));
export const { GET, POST } = createRouteHandler(agent, {
auth: jwt({ secret: process.env.JWT_SECRET!, issuer: 'https://auth.example.com', audience: 'agent-api' }),
// Customers use their own sessions; supervisors may read and decide in any session.
authorizeSession: ({ principal, sessionId, action }) =>
(principal !== undefined && ownerOf(sessionId) === principal.id) || (action !== 'chat' && isSupervisor(principal)),
authorizeApproval: ({ principal }) => isSupervisor(principal),
});In an agent directory built with lousho build --target=node-server (or
docker), export the same rules from auth.ts. Here users file expense
claims, a manager decides them, and nobody approves their own:
// my-agent/auth.ts
import { apiToken } from '@lousho/build-ai-agent/auth';
import type { ApprovalAccessRequest, SessionAccessRequest } from '@lousho/build-ai-agent';
export default [
apiToken(process.env.ALICE_TOKEN!, { id: 'alice' }),
apiToken(process.env.BOB_TOKEN!, { id: 'bob' }),
apiToken(process.env.MANAGER_TOKEN!, { id: 'manager' }),
];
// Session ids are `<user id>-<suffix without '-'>`.
const ownerOf = (sessionId: string) => sessionId.slice(0, Math.max(sessionId.lastIndexOf('-'), 0));
// Users use their own sessions; the manager may open any session to review a claim.
export function authorizeSession({ principal, sessionId }: SessionAccessRequest): boolean {
return principal?.id === 'manager' || (principal !== undefined && ownerOf(sessionId) === principal.id);
}
// Only the manager decides, and never on a claim of their own.
export function authorizeApproval({ principal, approval }: ApprovalAccessRequest): boolean {
return principal?.id === 'manager' && approval.principal?.id !== 'manager';
}Alice's paused claim in session alice-1 is then decided by
POST /chat/alice-1/approvals/:id with the manager's token (200); with
alice's own token it is a 403 (LOUSHO_APPROVAL_FORBIDDEN). Without these
exports the built server keeps the defaults, so only alice could decide it.
Two requests that decide the same approval get one continuation: while the
winning decision's continuation is still running (on the same server process),
any other decision of that approval is a 409 (LOUSHO_APPROVAL_CONFLICT),
whether it arrived at the same moment or while the decided tool was running;
once that continuation has ended, a decision is a 404
(LOUSHO_APPROVAL_NOT_FOUND). A decision handled by another replica is a
404 there.
The principal is saved with the run's checkpoints and approval snapshots, so a
run resumed in another process (after a restart, agent.approvals.resolve(),
agent.resume(), a session's pending turn) acts for the same caller. A call
that continues an unfinished checkpointed run must pass no principal (it then
continues as the saved one) or the same one; another caller's
send(message, { sessionId, principal }) throws LOUSHO_CONFIG_INVALID, so
its input never runs under someone else's identity. Checkpoints and snapshots
saved before this release have no principal and resume without one.
agent.fork() / AgentExecutor.fork() copy a run's checkpoint, principal
included: the fork is the same caller's run. A pending approval is not copied;
the fork asks again. session.fork() copies only the conversation: the fork
has no principal of its own, each of its turns runs with the principal its
send() passes, and a pending approval stays with the original session.
In-process sub-agents (the task tool and background tasks) act for the
lead's caller, also when their approval is
resumed. A remote sub-agent (remoteAgent()) is not told who
the caller is: the remote server's own route auth decides its principal (for
example the apiToken() service principal of the lead's token).
The principal, claims included, is stored in checkpoints and approval records,
which already hold the transcript; treat those stores as personal data. It is
not added to AgentEvents (permission.decision included), OpenTelemetry
spans or trace files, and error messages do not name callers.