Embedding & Multi-tenancy
Use a scope to separate an agent's work by tenant, user, workspace, or record.
| Value | Purpose |
|---|---|
scope_id | Stable application identifier used to partition chat-session memory, stores, and credentials. |
session_id | One conversation. Send it to continue that conversation. |
context | Application facts for the turn. The React widget calls this prop scopeContext. |
Several conversations can share one scope. Reusing a scope does not resume a conversation.
Authenticate the scope
A runtime token with a scope_id claim pins its caller to that scope: history, session reads, resumes, and session actions see only that scope's sessions, and a session_id from another scope starts a separate session. A token without a scope_id claim can filter history by any scope and resume any of the agent's sessions. A scoped token alone does not isolate tenants: the direct store routes ignore it, and the scope does not decide which of your users may act in it.
For a multi-tenant product, route runtime calls through your application backend:
- Authenticate each client request with your application's login.
- Authorize the requested scope and operation. For an existing session, check that the user can access that session in that scope. Apply these checks to chat, history, confirmations, and session actions.
- With the default platform JWT verifier, mint a short-lived runtime token on your backend using
POST /api/agents/{agent_id}/tokens. Include the authorized scope and application facts.ttlis in seconds, with a default of 3600 and a maximum of 86400. The response is201withtokenandexpires_at.contextvalues must be strings.
{
"ttl": 300,
"scope_id": "tenant:acme",
"context": {
"tenant_id": "acme",
"plan": "premium"
}
}- Keep the runtime token on your backend and use it to forward the authorized request. Record each created session's scope and ownership before allowing it to be resumed.
- Expose only the methods and paths your application needs. Set history filters on the backend and check session ownership before reads, changes, deletions, and actions. Reject unsupported paths.
Returning a runtime token to the browser lets the caller bypass your proxy's checks. Tokens minted by an automation token do not establish a separate product-user identity. A product JWT signed with your own secret does not work on a hosted chat URL. See Authentication for credential types and identity settings.
Use stable identifiers such as tenant:acme or case:merchant-123, not display names. Renaming a customer should not change its data partition.
Claim and request precedence
Chat requests can also supply scope and context:
{
"message": "Show open support tickets",
"scope_id": "tenant:acme",
"context": { "page": "support" }
}A verified token's scope_id claim overrides the request body's value. Without that claim, the runtime accepts the body's scope, except that an end-user session token can name only the empty scope, its own user ID, or <user ID>/<suffix>, where the suffix is 1 to 128 characters from A-Za-z0-9:_.-. Use the last form to separate state per record, such as user-123/taxpayer:TP-1001. Any other scope fails with 403 SCOPE_FORBIDDEN. Your backend must authorize and set the scope on every forwarded request.
Context merges per key: claim values win on conflict, and other body keys remain. Those body values are untrusted. Do not use the merged scopeContext alone to authorize an operation.
React integration
Point the widget at your application proxy. The proxy serves the runtime paths used by the widget and preserves streaming responses. It authenticates the application token returned by getAppToken and adds its own runtime credential to authorized upstream calls.
import { ChatWidget } from '@amodalai/react/widget';
import '@amodalai/react/widget/style.css';
function AgentPanel({ tenantId, getAppToken }: { tenantId: string; getAppToken: () => Promise<string> }) {
return (
<ChatWidget
key={tenantId}
serverUrl="https://app.example.com/api/agent"
scopeId={`tenant:${tenantId}`}
scopeContext={{ tenant_id: tenantId }}
getToken={getAppToken}
/>
);
}The application token authenticates the browser to your backend. The user widget prop does not send an identity. A key tied to the scope remounts the widget when the tenant changes.
Continue a conversation
A chat request without session_id starts a session. To keep one conversation across navigation, save its ID and pass it back when the panel remounts:
import { useState } from 'react';
import { ChatWidget } from '@amodalai/react/widget';
function ScopedChat({
userId,
scopeId,
getAppToken,
}: {
userId: string;
scopeId: string;
getAppToken: () => Promise<string>;
}) {
const storageKey = `amodal-session:${userId}:${scopeId}`;
const [resumeSessionId] = useState(() => localStorage.getItem(storageKey));
return (
<ChatWidget
serverUrl="https://app.example.com/api/agent"
scopeId={scopeId}
getToken={getAppToken}
resumeSessionId={resumeSessionId ?? undefined}
onStateChange={({ sessionId }) => {
if (sessionId) localStorage.setItem(storageKey, sessionId);
}}
/>
);
}Mount ScopedChat with a key containing the signed-in user and scope, because its state initializer runs only on mount. Clear saved session IDs on logout when a browser is shared. The backend must authorize the saved session before forwarding a resume or history request.
For a custom chat interface, read the session ID from the stream's init event. Send it as session_id to streamChat, or as sessionId in RuntimeClient.chatStream options. Load prior messages with getSessionHistory. Use the proxy URL and application authentication for these calls too.
Scoped resources
| Resource | Chat-session behavior |
|---|---|
| Sessions | Labeled with a scope. A token with a scope_id claim lists and resumes only that scope's sessions; the backend authorizes access within a scope. |
| Memory | Stored and recalled within the session's scope. |
| Stores | Agent store tools use the session's scope. Stores marked shared use the global partition and are read-only through scoped tools. |
| Credentials | scope:KEY resolves a secret for the current scope. |
Without a scope, chat uses the empty-string partition, which does not separate tenants. The hosted runtime does not enforce scope.requireScope; see Scope.
The direct /api/stores routes and React store hooks always use the global partition. They do not inherit ChatWidget.scopeId. For a multi-tenant data UI, use scoped agent tools or a backend that enforces the partition.
Scope context in connections and tools
A connection's contextInjection copies scope context values into outbound requests, such as a tenant_id into an X-Tenant-Id header. A custom tool reads the same values through ctx.scopeContext. Client-supplied keys are merged into this context, so use it for application facts such as the current page, and enforce tenant authorization in the receiving API.
Per-scope credentials
Reference a scope-specific secret in a connection:
{
"auth": { "type": "bearer", "token": "scope:USER_API_TOKEN" }
}The credential resolver looks up USER_API_TOKEN for the session's scope. In hosted deployments, manage scoped secrets through PUT /api/agents/{agent_id}/scopes/{scope_id}/secrets. Keep this operation on your backend.
amodal dev does not resolve scope: references, and a custom runtime host must supply its own credential resolver for them.