Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Embedding & Multi-tenancy

This guide is for SaaS builders who want to embed an Amodal agent in their application with per-user or per-tenant data isolation. The key primitive is scope_id — a string that partitions sessions, memory, and stores so each tenant sees only their own data.

How Scope Works

When a chat request includes a scope_id, the runtime uses it to partition every stateful resource:

  • Sessions — conversation history is isolated per scope
  • Memory — agent memories are stored and recalled per scope
  • Stores — store documents are partitioned per scope
  • Credentialsscope:KEY resolves 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.

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 recent transactions",
  "scope_id": "household-abc-123",
  "context": {
    "household_id": "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": "household-abc-123",
  "scopeContext": {
    "household_id": "abc-123",
    "plan": "premium"
  }
}

JWT claims take precedence over body fields. This is the recommended approach for production — it prevents clients from spoofing scope.

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.

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.

What Gets Scoped

ResourceBehaviorConfigured in
SessionsEach scope gets independent conversation historyAutomatic
MemoryAgent memories are stored and recalled per scopeMemory
StoresStore documents are partitioned per scopeStores
Credentialsscope:KEY resolves per-scope secretsConnections

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 Studio

Studio 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:

{
  "household-abc-123": {
    "credentials": {
      "USER_API_TOKEN": "tok_dev_abc123"
    }
  },
  "household-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: Finance SaaS

A personal finance application where each household has its own agent scope.

amodal.json — require scope in production:

{
  "name": "finance-agent",
  "version": "0.1.0",
  "models": {
    "main": {
      "provider": "anthropic",
      "model": "claude-sonnet-4-20250514"
    }
  },
  "scope": {
    "requireScope": true
  }
}

connections/app-api/spec.json — inject household_id into every API call:

{
  "baseUrl": "https://api.finance-app.com",
  "auth": {
    "type": "bearer",
    "token": "env:FINANCE_API_TOKEN"
  },
  "contextInjection": {
    "household_id": {
      "in": "header",
      "field": "X-Household-Id",
      "required": true
    }
  }
}

stores/preferences.json — household preferences, scoped by default:

{
  "name": "preferences",
  "entity": {
    "name": "HouseholdPreference",
    "key": "{key}",
    "schema": {
      "key": { "type": "string" },
      "value": { "type": "string" }
    }
  }
}

stores/categories.json — shared transaction categories across all households:

{
  "name": "categories",
  "shared": true,
  "entity": {
    "name": "Category",
    "key": "{slug}",
    "schema": {
      "slug": { "type": "string" },
      "label": { "type": "string" }
    }
  }
}

React embedding — pass the household as scope:

<ChatWidget
  serverUrl="https://finance-agent.example.com"
  user={{ id: userId }}
  scopeId={householdId}
  scopeContext={{ household_id: householdId }}
  getToken={() => getHouseholdToken(householdId)}
/>

With this setup:

  • Each household's conversations, memories, and preferences are isolated
  • The agent's API calls automatically include the household identifier
  • The shared categories store is readable by all households
  • Requests without a scope are rejected