Embedding & Multi-tenancy
This guide is for SaaS builders who want to embed an Amodal agent in their application with per-user, per-tenant, or per-object data isolation. The key primitive is scope_id — a stable string chosen by your application that tells Amodal which tenant, user, account, case, workspace, or record the agent is operating inside.
If you are new to Amodal's runtime model, keep these terms separate:
- Session: the specific conversation or workflow run. This is what you resume with
session_id. - Scope: the app boundary for data, memory, stores, credentials, and visibility. This is what you pass as
scope_id. - Scope context: the current facts from your app for this turn. This is what you pass as
contextorscopeContext.
Same scope does not mean same chat. A user can have several sessions inside the same scope, and each session has its own conversation history.
How Scope Works
When a chat request includes a scope_id, the runtime uses it to partition every stateful resource:
- Sessions — session records are labeled and filterable by scope
- Memory — agent memories are stored and recalled per scope
- Stores — store documents are partitioned per scope
- Credentials —
scope:KEYresolves to per-scope secrets
The embedding application controls who gets which scope_id. The runtime does not enforce authentication itself — it trusts the value it receives. Your app is the security boundary.
Choosing Scope IDs
Use the smallest stable boundary that matches the work your agent is doing:
| Product surface | Good scope ID | Why |
|---|---|---|
| Tenant-wide support assistant | tenant:acme | All conversations operate against one customer's support data. |
| Merchant underwriting case | case:merchant-123 | The agent sees one case, its documents, and its review state. |
| Portfolio review page | account:42 | The agent can use account-specific holdings, policy, and memory. |
| Admin console | org:acme:admin | Admin work is separated from end-user work. |
Use stable identifiers, not display names. If "Acme Hotels" becomes "Acme Hospitality," historical sessions should still belong to the same scope.
Passing Scope
Two methods, depending on your auth setup.
Request Body
Include scope_id and context in the POST body to /chat/stream:
{
"message": "Show me my open support tickets",
"scope_id": "tenant-abc-123",
"context": {
"tenant_id": "tenant-abc-123",
"plan": "premium"
}
}JWT Claims
If using JWT authentication, include scope_id and scopeContext as claims in the token payload:
{
"sub": "user-456",
"scope_id": "tenant-abc-123",
"scopeContext": {
"tenant_id": "tenant-abc-123",
"plan": "premium"
}
}JWT claims take precedence over body fields. This is the recommended approach for production — it prevents clients from spoofing scope.
Session Continuity
Scope isolates the work. Session IDs continue a specific conversation.
When the runtime receives a chat request without session_id, it starts a new session even if scope_id matches an older session. This is intentional: implicit "resume the latest session for this scope" can pick up stale history after failed tool calls, browser refreshes, evals, or parallel tabs.
To keep a chat thread alive across page navigation:
- Send the first message with
scope_idandcontext. - Save the returned session id from the stream's
initevent or the widget'sonStateChange. - Store it under a key derived from your scope, such as
amodal-session:${scopeId}. - On remount, load that session's transcript with
getSessionHistory(...). - Pass the saved
session_idon the next chat turn.
Minimal pattern with ChatWidget:
const storageKey = `amodal-session:${scopeId}`;
const [resumeSessionId] = useState(() => localStorage.getItem(storageKey));
<ChatWidget
serverUrl="https://your-agent.example.com"
user={{ id: userId }}
scopeId={scopeId}
scopeContext={{ tenant_id: scopeId }}
resumeSessionId={resumeSessionId ?? undefined}
onStateChange={({ sessionId }) => {
if (sessionId) localStorage.setItem(storageKey, sessionId);
}}
/>;For a custom chat surface, use the same pattern manually: include the stored session_id in streamChat(...) or RuntimeClient.chatStream(...), capture the init event's session_id, and load the transcript with getSessionHistory(...) when your surface remounts.
React SDK
The ChatWidget accepts scopeId and scopeContext props. The SDK sends these with every chat request automatically.
import { ChatWidget } from '@amodalai/react';
function AgentPanel({ userId, tenantId }: { userId: string; tenantId: string }) {
return (
<ChatWidget
serverUrl="https://your-agent.example.com"
user={{ id: userId }}
scopeId={tenantId}
scopeContext={{ tenant_id: tenantId }}
getToken={() => getAccessToken()}
/>
);
}The getToken callback should return a JWT or API key for authenticating requests to your agent server. It can be async to support token refresh.
getToken is for the case this page describes: your product owns user identity and mints a token (typically a jwt_secret/oidc end-user JWT carrying scope_id/scopeContext). If instead the agent is served by Amodal and uses hosted login (the default), auth is cookie-backed and injected at the edge — the app makes same-origin calls and omits getToken. See Authentication for the full model and how to choose.
Context Injection
Scope context values can be injected into outbound API calls automatically. This is how the agent passes tenant identifiers to your backend without the LLM needing to know about them.
Configure contextInjection in a connection's spec.json:
{
"baseUrl": "https://api.your-app.com",
"auth": {
"type": "bearer",
"token": "env:APP_API_TOKEN"
},
"contextInjection": {
"tenant_id": {
"in": "header",
"field": "X-Tenant-Id",
"required": true
}
}
}Every API call the agent makes to this connection will include the X-Tenant-Id header, populated from the tenant_id key in scope context. If required is true and the key is missing, the request fails with an error instead of silently omitting it.
Injection targets: header, query, path, body. See Connections — Context Injection for the full reference.
Reading scope context in a tool
Beyond connection injection, a custom tool handler can read the turn's scope context directly as ctx.scopeContext (a Record<string, string>, or undefined when the turn carried none). Use it as a fallback for an ambient fact the operator's UI knows but the LLM may not pass as a parameter:
export default async function generateReport(params, ctx) {
// Prefer an explicit argument; fall back to the workspace the user is viewing.
const missionId =
String(params.mission_id ?? '').trim() ||
(typeof ctx.scopeContext?.mission_id === 'string' ? ctx.scopeContext.mission_id : '');
if (!missionId) throw new Error('No mission in scope — pass mission_id or open a mission first.');
// ...
}Requires @amodalai/cli ≥ 0.2.11. Available on inline tools; see Tools — the handler context.
What Gets Scoped
| Resource | Behavior | Configured in |
|---|---|---|
| Sessions | Session records carry scope IDs for filtering, auditing, and explicit resume | Automatic |
| Memory | Agent memories are stored and recalled per scope | Memory |
| Stores | Store documents are partitioned per scope | Stores |
| Credentials | scope:KEY resolves per-scope secrets | Connections |
Without a scope_id, all requests share a single global partition (the empty-string scope). This is fine for single-tenant or development use.
Viewing Scope Usage in Amodal
Amodal uses the same scope_id values from session history for operator visibility:
- Sessions shows scope filter chips with session counts, so you can review one tenant, user, or workspace at a time.
- Cost & Usage shows a scope comparison card across the selected date range. Selecting a scope focuses the model, deploy, trend, and highest-cost session panels on that scope.
- Session replay includes the raw scope ID in the session metadata.
Amodal treats scope_id as an opaque stable identifier. Your application owns the mapping from that identifier to human-readable tenant names. For example, you might pass tenant_01H... to Amodal and show "Acme Hotels" in your own admin UI. Use stable IDs instead of mutable display names so historical sessions and cost reports remain consistent if a customer renames their account.
Shared Stores
By default, stores are partitioned per scope. To make a store shared across all scopes — for example, a product catalog or reference data — add "shared": true to the store definition:
{
"name": "product-catalog",
"shared": true,
"entity": {
"name": "Product",
"key": "{sku}",
"schema": {
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number" }
}
}
}Shared stores are readable by all scopes. See Stores — Scoped vs. Shared for details.
Per-scope Credentials
Connection auth values can reference per-scope secrets using the scope:KEY prefix:
{
"auth": {
"type": "bearer",
"token": "scope:USER_API_TOKEN"
}
}The runtime resolves scope:USER_API_TOKEN by looking up USER_API_TOKEN in the current scope's credential store.
Local development: Define scope credentials in .amodal/scopes.json:
{
"tenant-abc-123": {
"credentials": {
"USER_API_TOKEN": "tok_dev_abc123"
}
},
"tenant-def-456": {
"credentials": {
"USER_API_TOKEN": "tok_dev_def456"
}
}
}Production: Credentials are managed by the platform's credential resolver, keyed by scope ID.
requireScope
In production, enable requireScope in amodal.json to reject any request that does not include a scope_id:
{
"scope": {
"requireScope": true
}
}This prevents accidental unscoped access — if a client forgets to pass a scope, the request fails immediately instead of writing to the global partition.
Example: Support Portal
A support portal where each customer workspace has its own agent scope.
amodal.json — require scope in production:
{
"name": "support-agent",
"version": "0.1.0",
"scope": {
"requireScope": true
}
}amodal/connections/app-api/spec.json — inject tenant_id into every API call:
{
"baseUrl": "https://api.example.com",
"auth": {
"type": "bearer",
"token": "env:APP_API_TOKEN"
},
"contextInjection": {
"tenant_id": {
"in": "header",
"field": "X-Tenant-Id",
"required": true
}
}
}amodal/stores/preferences.json — workspace preferences, scoped by default:
{
"name": "preferences",
"entity": {
"name": "WorkspacePreference",
"key": "{key}",
"schema": {
"key": { "type": "string" },
"value": { "type": "string" }
}
}
}amodal/stores/categories.json — shared ticket categories across all tenants:
{
"name": "ticket-categories",
"shared": true,
"entity": {
"name": "Category",
"key": "{slug}",
"schema": {
"slug": { "type": "string" },
"label": { "type": "string" }
}
}
}React embedding — pass the tenant as scope:
<ChatWidget
serverUrl="https://support-agent.example.com"
user={{ id: userId }}
scopeId={tenantId}
scopeContext={{ tenant_id: tenantId }}
getToken={() => getTenantToken(tenantId)}
/>With this setup:
- Each tenant's conversations, memories, and preferences are isolated
- The agent's API calls automatically include the tenant identifier
- The shared
ticket-categoriesstore is readable by all tenants - Requests without a scope are rejected