# Multi-tenant SaaS agent (/docs/multi-tenant-saas-agent)



A multi-tenant SaaS agent is the use case the `tenantId` field on
the [audit row](/docs/auditable-call-row) was added for. One runtime serves dozens to
thousands of customers; every LLM call and every tool call has to
trace back to the customer whose user message triggered it.

This page describes the use-case shape. For the primitive reference
(the `runtime.tenant` accessors), see
[runtime.tenant facet](/docs/tenant-facet). For the ops guide (RLS
templates, deployment checklist), see
[Multi-tenant deployments](/docs/multi-tenant).

**Related shapes.**
[Regulated-domain agent](/docs/regulated-domain-agent) if the
SaaS serves a regulated vertical (HIPAA, SOC 2, FedRAMP).
[Internal knowledge agent](/docs/internal-knowledge-agent) if
each tenant has its own retrieval corpus.
[Research agent](/docs/research-agent) if turns inside a tenant
fan out into multi-subagent investigations.

The pattern shows up four places: an internal platform team
exposing "AI for everything" across business units, an AI
consultancy whose tenants are client engagements, an ISV embedding
an agent in a product whose own customers nest below it, and a
team running Pleach under one Anthropic Workspace or OpenAI Project
on an Enterprise contract — where `tenantId` partitions employees,
teams, or cost centers instead of external customers. All four
need the same answer: which axis spent what, and on what.

## What you're building [#what-youre-building]

A single runtime construction path, parameterized per request by
the authenticated tenant. The runtime:

* Routes to the tenant's BYOK credentials when present; falls
  back to the platform pool when not.
* Stamps `tenantId` on every audit row, including the rows
  written by spawned [subagents](/docs/subagents).
* Rolls subagent token spend back to the parent turn, so nested
  fan-out doesn't get stranded under a child `sessionId`.

Finance runs one `GROUP BY tenant_id` against
`harness_auditable_calls` (the [audit ledger](/docs/audit-ledger)) and produces the month's invoice. No
parallel cost pipeline.

## Per-tenant runtime construction [#per-tenant-runtime-construction]

The [runtime](/docs/session-runtime) is built per request. The tenant resolver runs first;
its output is the input to provider selection and [storage](/docs/storage) scoping.

The `runtime.tenant` facet is the load-bearing piece. Set
`tenantId` (and optionally `subTenantId`) on `SessionRuntimeConfig`,
and the runtime stamps `tenant_id` on every write site — audit
rows, [harness event log](/docs/event-log), [checkpoints](/docs/checkpointing) — automatically. See
[Tenant facet](/docs/tenant-facet) and [Facets](/docs/facets) for
the broader facet model.

```typescript
// lib/runtime.ts
import { SessionRuntime, AnthropicSdkProvider, AiSdkProvider, definePleachPlugin } from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver }   from "@pleach/core/checkpointing";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";

const platformOpenRouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });

interface TenantConfig {
  tenantId:     string;
  subTenantId?: string;           // e.g. workspace under the org
  providerType: "anthropic" | "openai" | "platform-default";
  apiKey?:      string;           // BYOK — tenant-supplied
  model?:       string;
  monthlyCapUsd?: number;
}

export async function buildTenantRuntime(req: AuthedRequest) {
  const tenant = await loadTenant(req.user.orgId);

  return new SessionRuntime({
    storage:      new SupabaseAdapter({ client: supabase }),
    checkpointer: new SupabaseSaver({ client: supabase }),
    provider:     pickProvider(tenant),
    plugins:      [definePleachPlugin("shared-tools", { tools: sharedTools })],
    tenant: {
      tenantId:    tenant.tenantId,
      subTenantId: req.user.workspaceId,    // optional second axis
    },
    context: {
      userId:         req.user.id,
      organizationId: req.user.orgId,
    },
  });
}

function pickProvider(tenant: TenantConfig) {
  if (tenant.providerType === "anthropic" && tenant.apiKey) {
    return new AnthropicSdkProvider({
      apiKey: tenant.apiKey,                            // tenant's key
      model:  tenant.model ?? "claude-sonnet-4-5",
    });
  }
  if (tenant.providerType === "openai" && tenant.apiKey) {
    return new AiSdkProvider({
      model: openai("gpt-4o", { apiKey: tenant.apiKey }),
    });
  }
  return new AiSdkProvider({                            // platform pool
    model:    platformOpenRouter("anthropic/claude-sonnet-4-5"),
    maxSteps: 5,
  });
}
```

The tenant's API key never appears in the audit ledger. The row
records `family`, `modelId`, and `transport` — not the credential.
And it never reaches the browser bundle: runtime construction is
server-side.

## Outbound HTTP through a tenant-routing gateway [#outbound-http-through-a-tenant-routing-gateway]

If outbound tool calls ride through an upstream gateway that
routes by a tenant header, the runtime ships `withTenantHeader`
to thread the facet's `tenantId` onto every request without
hand-plumbing.

```typescript
import { withTenantHeader } from "@pleach/core";

// inside a tool handler — `ctx.tenantId` comes from runtime construction
const fetchForTenant = withTenantHeader(fetch, {
  header:   "x-tenant-id",   // whatever the gateway expects
  tenantId: ctx.tenantId,
});

await fetchForTenant("https://gateway.internal/v1/lookup", {
  method: "POST",
  body:   JSON.stringify({ query }),
});
```

The header value is bound once at wrapper construction; the host
threads `ctx.tenantId` from the per-request runtime into each
tool handler. A tool that forgets to use the wrapper is the only
way for a request to leave without the header — that single
coupling site (one closure per tool) is the audit anchor.

## What the audit row carries [#what-the-audit-row-carries]

Every row in `harness_auditable_calls` carries the six fields
finance and compliance both need:

| Field            | Source                 | Why finance cares           |
| ---------------- | ---------------------- | --------------------------- |
| `tenant_id`      | runtime context        | invoice partition           |
| `turn_id`        | runtime, per user turn | unit of work                |
| `tool_name`      | runtime, per tool call | what the model actually did |
| `subagent_depth` | runtime, per spawn     | nested fan-out attribution  |
| `model_id`       | resolved at call time  | rate card lookup            |
| `token_usage`    | provider response      | cost calculation            |

See [Auditable call row](/docs/auditable-call-row) for the full
column list.

## OTel spans inherit the tenant [#otel-spans-inherit-the-tenant]

Once `runtime.tenant` is set, every emitted span carries a
`pleach.tenant_id` attribute. A per-tenant trace query in your
OTel backend is a single attribute filter — no join effort, no
custom processor, no log correlation pass.

If `subTenantId` is set, it rides alongside as
`pleach.sub_tenant_id`. The two attributes are stable across
versions; treat them as part of the observability contract. See
[OTel observability](/docs/otel-observability) for the full
attribute schema.

## Cross-tenant cache pollution is prevented by construction [#cross-tenant-cache-pollution-is-prevented-by-construction]

The cache [fingerprint](/docs/fingerprint) has four gaps, and `tenantId` is one of
them. Two tenants asking the literally identical question
fingerprint to different cache keys. A leak from tenant A's cache
into tenant B's response is structurally impossible — there's no
shared key to read from.

See [Cache](/docs/cache) for the fingerprint shape and the other
three gaps (model identity, prompt, tool surface).

## Inherited audit gates [#inherited-audit-gates]

Adopting `@pleach/compliance` brings two CI gates that catch the
hand-rolled multi-tenancy mistakes:

* `audit:tenant-scoping` — flags storage reads and writes that
  don't carry a tenant predicate.
* `audit:harness-event-log-tenant-id-required` — flags emits to
  `harness_event_log` that don't stamp `tenant_id`.

Both gates run in CI; both fail the build on violation. The
runtime's facet plumbing is what makes the gates green by
default — but the gates are the proof that no code path slipped
around the facet.

## The per-tenant cost rollup [#the-per-tenant-cost-rollup]

The month's invoice is one query.

```sql
select
  tenant_id,
  count(*) filter (where call_kind = 'llm')  as llm_calls,
  count(*) filter (where call_kind = 'tool') as tool_calls,
  sum(input_tokens)  as input_tokens,
  sum(output_tokens) as output_tokens
from harness_auditable_calls
where created_at >= date_trunc('month', now())
group by tenant_id
order by input_tokens + output_tokens desc;
```

For a per-engagement consultancy or a per-customer ISV, the
exact same shape — different `tenant_id` semantics, same query.

## Subagent fan-out, attributed to the parent turn [#subagent-fan-out-attributed-to-the-parent-turn]

A typed `SpawnTreeState` keeps nested-spawn cost on the
parent's books. Without it, a deep tree of work would be charged
to the child `sessionId` and the parent would look free.

```sql
-- "Whose user turn caused this token spend?"
select
  parent_turn_id,
  sum(input_tokens)  as input_tokens,
  sum(output_tokens) as output_tokens,
  max(subagent_depth) as deepest_spawn
from harness_auditable_calls
where tenant_id   = $1
  and created_at >= date_trunc('month', now())
group by parent_turn_id;
```

`parent_turn_id` resolves to the originating user message. A
five-level fan-out still rolls back to one row in this output.
See [Subagents](/docs/subagents) for the spawn lifecycle.

## Tenant nesting for ISVs [#tenant-nesting-for-isvs]

ISVs embedding the runtime have two tenant axes: their direct
customer (an org) and a sub-tenant inside it (a workspace, a
project, a customer's own end-user). Both ride on the facet.

```typescript
tenant: {
  tenantId:    customer.orgId,      // ISV's customer
  subTenantId: endUser.workspaceId, // customer's own sub-tenant
},
context: {
  organizationId: customer.orgId,   // mirror, for storage RLS
  userId:         endUser.id,
},
```

A customer asking "how much did my workspace W cost?" is one
`where sub_tenant_id = $1` predicate — no JSON extraction, no
metadata fishing. See [Multi-tenant](/docs/multi-tenant) for the
broader isolation pattern and [Recipes](/docs/recipes) (recipe
10\) for the per-sub-tenant rollup query.

## Cap enforcement: in the runtime, not in finance [#cap-enforcement-in-the-runtime-not-in-finance]

A monthly cap is enforced before the call, not after. A safety
policy that reads the current month's spend from the ledger and
refuses dispatch is one of the canonical capability-subtracting
policy shapes.

```typescript
// lib/safety/monthlyCap.ts
import { defineSafetyPolicy, safetyPolicyId } from "@pleach/core/safety";

export const monthlyCapPolicy = defineSafetyPolicy({
  id:          safetyPolicyId("saas.monthly-cap"),
  version:     "1.0.0",
  enforcement: "refusal",
  content: `
[Monthly spend cap policy]
This workspace runs under a hard monthly AI
budget. When the operator's pre-dispatch hook
flags "monthly_cap_exceeded" on the runtime
context, refuse to invoke any model or tool.
Reply with:

  "This workspace has hit its monthly AI budget.
   Contact your administrator."

Do not paraphrase. Do not offer a workaround.
  `.trim(),
});
```

`defineSafetyPolicy` carries the operator's stated REFUSAL
posture into the system prompt and the audit row. The actual
cap-vs-spend check runs in the host's pre-dispatch hook (one
ledger query per turn) and short-circuits the model invocation
when the cap is hit. The audit row records the policy `id` +
`version`, so a later review can ask "which turns refused
under the monthly cap" in one query.  See [Safety](/docs/safety)
for the policy contract.

## Project layout [#project-layout]

The biggest delta from the [baseline](/docs/project-layout#a-layout-that-works):
`pleach/runtime.ts` no longer exports a singleton instance. It
exports a `buildTenantRuntime(req)` factory. Everything else
hangs off that change.

```
my-app/
  src/
    pleach/
      runtime.ts                # exports buildTenantRuntime(req) — NOT a singleton
      tenant.ts                 # loadTenant(orgId) — resolver the factory calls first
      providers/
        pick.ts                 # pickProvider(tenant) — BYOK vs platform pool
      gateway/
        with-tenant-header.ts   # withTenantHeader wiring for outbound HTTP
      safety/
        monthly-cap.ts          # defineSafetyPolicy — refuses on cap exceeded
      otel/
        setup.ts                # span attributes including pleach.tenant_id
    app/
      api/agents/[id]/route.ts  # imports buildTenantRuntime, calls it per request
  ops/
    rollups/
      by-tenant.sql             # the per-tenant cost GROUP BY
      cap-enforcement.sql       # what the safety policy reads
```

What changes from the baseline:

* **`runtime.ts` exports a factory, not an instance.** Every
  request resolves a tenant and builds a `SessionRuntime` with
  that tenant's facet, provider key, and storage scope wired in.
  This is the load-bearing structural change — it's also why
  cross-tenant cache pollution is
  [prevented by construction](#cross-tenant-cache-pollution-is-prevented-by-construction)
  rather than by a runtime check.
* **`tenant.ts` runs first.** The factory's first step is the
  tenant resolver; its output drives every subsequent decision
  (provider, key, storage scope, cap). Splitting it out keeps the
  resolver swappable for tests.
* **`providers/pick.ts` is the BYOK seam.** Anthropic-BYOK,
  OpenAI-BYOK, and platform-pool selection live in one file so
  the auth-key paths are auditable in a single read.
* **`gateway/with-tenant-header.ts` threads the facet onto
  outbound HTTP.** Hand-plumbing tenant headers across tools
  invites the one tool that forgets; centralizing the wiring is
  the failure-mode fix.
* **`ops/rollups/*.sql` ship in the repo.** Per-tenant cost
  rollups and cap-enforcement reads are the same SQL the
  product runs and the same SQL finance reviews. Keeping them in
  the repo means the column shape stays in sync with the
  [audit row](#what-the-audit-row-carries).

The [`audit:tenant-scoping`](#inherited-audit-gates) gate makes
the directory layout enforceable — a storage read without a
tenant predicate fails CI, not production.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="runtime.tenant facet" href="/docs/tenant-facet" description="The primitive reference — `tenantId` + `subTenantId` accessors and the type-level surface." />

  <Card title="Multi-tenant deployments" href="/docs/multi-tenant" description="The operations guide — RLS templates, per-tenant cost rollups, deployment checklist." />

  <Card title="OTel observability" href="/docs/otel-observability" description="The pleach.tenant_id span attribute and the rest of the OTel schema." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The harness_auditable_calls schema and the indexes the rollups read from." />

  <Card title="Subagents" href="/docs/subagents" description="SpawnTreeState and how nested fan-out gets attributed to the parent turn." />

  <Card title="Auditable call row" href="/docs/auditable-call-row" description="The full column list, including tenantId and the payload JSON schema." />
</Cards>
