Identity And Access
How PAI derives a storage partition and an actor from one trusted identity.
identity is the trusted, server-resolved caller. It answers "who is this
request, and what does it own?" Everything PAI needs for isolation and
attribution is derived from it.
// personal app
identity = { userId };
// team app
identity = { userId, workspaceId };
// enterprise app
identity = { userId, orgId, workspaceId };resolveIdentity authenticates the request and returns the identity the caller
is allowed to act as. PAI then derives two things from it, and needs no separate
permission check on every thread operation.
The Two Derivations
| Derived | From | Used for |
|---|---|---|
scopeKey | createPai({ scopeKey }) | The storage partition. An opaque string that storage, realtime, files, and scoped capabilities isolate on. |
userKey | createPai({ userKey }) | The actor. Telemetry, file provenance, per-user capability instances, and steer-merge. |
This is the distinction that matters: a partition is not an actor. Two teammates in one workspace share a partition and see the same threads, but they are different actors. Declaring them separately is what makes that possible.
const pai = createPai({
agents: { main: agent },
scopeKey: (identity) => identity.orgId,
userKey: (identity) => identity.userId,
});identity and clientData cross durable runtime boundaries and must contain
only strict JSON values. Keep request objects, functions, class instances,
secrets, and live resources in server-owned application services instead.
clientData is the untrusted half: extra per-request context the client sent,
such as the active view or selected document. Never derive a partition from it.
Composing The Key
scopeKey returns the string providers partition on. The format is yours — PAI
stores it and never parses it.
// one field: the value is the key
scopeKey: (identity) => identity.orgId;
// several: join them with a separator their values cannot contain
scopeKey: (identity) => `${identity.orgId}:${identity.userId}`;Use only durable tenancy fields. A display name or a preference would orphan every existing thread the moment it changed. To separate two applications sharing one store, put the application's name in the key.
Two properties are yours to keep. Distinctness: a key built from more than
one field needs a separator those values cannot contain — if an orgId may
contain :, then `${orgId}:${userId}` can produce another tenant's key.
Percent-encode each field, or pick a separator the ids exclude. Stability:
the key is durable, so changing how you build it is a migration, not a
configuration change.
Cleanup Hooks Get The Identity
The key is opaque by design, so hooks that need to know who a partition belongs to receive the trusted identity rather than a decoded key:
onThreadDeleted: ({ identity, thread }) =>
clearUnread(identity.userId, thread.threadId);identity here is typed against the agent's own identity schema. Provider
inputs — saving a file, acquiring a capability — take scopeKey, because a
provider addresses a partition without interpreting it.
Owner-Scoped Apps
For personal apps, the partition is usually just the user:
const pai = createPai({
agents: { main: agent },
scopeKey: (identity) => identity.userId,
userKey: (identity) => identity.userId,
});
export const routes = createPaiHonoReceiver({
pai,
resolveIdentity: async ({ request }) => {
const user = await requireUser(request);
return { identity: { userId: user.id } };
},
});Every durable thread operation is addressed by the derived scope key plus a thread id.
If user_456 guesses thread_abc, the runtime looks for that id under that
user's own partition and does not find it.
Shared Scopes
For workspace/team apps, check product access before returning the identity:
const pai = createPai({
agents: { main: agent },
scopeKey: (identity) => identity.workspaceId,
userKey: (identity) => identity.userId,
});
export const routes = createPaiHonoReceiver({
pai,
resolveIdentity: async ({ request, body }) => {
const user = await requireUser(request);
const workspace = await requireWorkspaceMember(request, user);
return {
identity: { userId: user.id, workspaceId: workspace.id },
clientData: body?.clientData,
};
},
});requireWorkspaceMember(request, user) is application code. It can read the
active workspace from a server session, auth claim, route-aware framework
wrapper, or URL parsing helper. It should return only a workspace the user may
access.
Note that userId is in the identity but not in the key. Every member of the
workspace shares the partition, and userKey still distinguishes them.
If a product needs viewer/editor/admin differences, enforce that before returning the identity, in product routes, or before constructing a trusted direct/admin runtime client. PAI core does not expose a role matrix.
Authorization is accepted when a send is accepted. If that send waits in a
thread queue, PAI restores its validated JSON identity at admission and checks
that it still derives the captured stable keys; it does not invoke
resolveIdentity again. Applications that revoke access should explicitly stop
or recall already accepted work.
Hydrating Application Data
identity is an id tuple, not a place for application records. Load what agent
code needs from those ids in runtimeContext, which runs per episode and can be
async:
defineAgent({
identity: z.object({ userId: z.string(), orgId: z.string() }),
runtimeContext: async ({ identity }) => ({
org: await loadOrg(identity.orgId),
actor: await loadUser(identity.userId),
}),
});This is deliberately better than putting records in the identity itself: a queued turn replays the identity it was admitted with, so an embedded record would be stale by the time it ran, while a lookup from an id is always current.
Request Pipeline
Trusted Backend Sources
Trusted backend code does not need to call back through HTTP auth. Use direct runtime clients after your app has already decided who the code is acting for:
const client = runtime.client({ identity });
const admin = runtime.admin({ identity });If backend code acts on behalf of an end user, run your product policy before
constructing the direct client. runtime.admin() is for trusted host/runtime
operations and should stay server-side.
Never trust a client-provided identity:
// Do not do this.
identity: body.identity;Use auth claims, route params, server session state, or server-side lookups.