Identity And Scope
Decide who the request is, then let PAI isolate their threads.
Every PAI request asks one question first: who is this, and what may they see?
You answer that in resolveIdentity on the server. PAI then derives two strings from the identity:
scopeKey— the storage partition. Threads, files, and sandboxes live here.userKey— the actor. Telemetry, file provenance, and "who did this".
A partition is not an actor. Two teammates in one workspace share a partition and see the same threads. They are still different people.
Add to the first-app createPai call — do not replace the file:
const pai = createPai({
agents: { main: agent },
scopeKey: (identity) => identity.workspaceId,
userKey: (identity) => identity.userId,
});Resolve identity on every request
Add to the first-app receiver — do not replace server/http-server.ts:
createPaiExpressReceiver({
pai,
resolveIdentity: async ({ request }) => {
const user = await requireUser(request);
const workspace = await requireWorkspaceMember(request, user);
return {
identity: {
userId: user.id,
workspaceId: workspace.id,
},
};
},
});requireUser and requireWorkspaceMember are your code. request is a Fetch Request — use request.headers.get("cookie") or request.headers.get("authorization"), not Express req.user. Return only a workspace the caller may access.
Never take workspaceId from the JSON body. The client can send anything.
Resolve it from the session or from a server-checked URL.
In development, a header is fine:
resolveIdentity: ({ request }) => ({
identity: {
userId: request.headers.get("x-user-id") ?? "dev-user",
workspaceId: request.headers.get("x-workspace-id") ?? "dev",
},
}),Pick a scope key
The format is yours. PAI stores it and never parses it.
// personal app — each user is their own partition
scopeKey: (identity) => identity.userId,
// team app — the workspace is the partition
scopeKey: (identity) => identity.workspaceId,
// several fields — join with a separator those values cannot contain
scopeKey: (identity) => `${identity.orgId}:${identity.workspaceId}`,Use only durable tenancy fields. A display name would orphan every thread the moment it changed. Changing how you build the key is a migration, not a config tweak.
If a field may contain your separator, percent-encode each part.
Identity is ids, not records
Keep the identity small. Load application data in runtimeContext:
defineAgent({
identity: z.object({ userId: z.string(), workspaceId: z.string() }),
runtimeContext: async ({ identity }) => ({
workspace: await loadWorkspace(identity.workspaceId),
actor: await loadUser(identity.userId),
}),
});Each send runs with the identity of that request. Looking up records from ids keeps them current.
clientData is extra per-request context the client sent (the open document, the current view). It is untrusted. Never derive a partition from it.
Declare it on the agent and send it with the message:
defineAgent({
identity: z.object({ userId: z.string(), workspaceId: z.string() }),
clientData: z.object({ documentId: z.string().optional() }),
});await thread.send("Summarize this page", {
clientData: { documentId: openDoc.id },
});Tools that need the open document take it from mapContext / clientData as hints, then load the record on the server through ctx.context (filled from runtimeContext). Tools have no ctx.identity.
On the React page, the usual place is ThreadProvider (you can also pass it on chat.send / composer.submit options):
<Assistant.ThreadProvider
threadId={threadId}
clientData={{ documentId: openDoc.id }}
>
<Chat />
</Assistant.ThreadProvider>A mounted child can contribute more with Assistant.useClientData({ documentId: openDoc.id }). That hook writes into the thread's client data; it does not read it.
On the client, send credentials the same way you resolve them — cookies or a header function, not a workspace id in the JSON body:
export const paiClient = createPaiHttpClient<AssistantPai>({
url: "/api/pai",
credentials: "include",
});