Multi-tenant SaaS agent
The multi-tenant SaaS agent shape — recurring per-tenant pattern, the primitives that handle it, the ops surface around it.
A multi-tenant SaaS agent is the use case the tenantId field on
the audit 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. For the ops guide (RLS
templates, deployment checklist), see
Multi-tenant deployments.
Related shapes. Regulated-domain agent if the SaaS serves a regulated vertical (HIPAA, SOC 2, FedRAMP). Internal knowledge agent if each tenant has its own retrieval corpus. 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
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
tenantIdon every audit row, including the rows written by spawned 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) and produces the month's invoice. No
parallel cost pipeline.
Per-tenant runtime construction
The runtime is built per request. The tenant resolver runs first; its output is the input to provider selection and 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, checkpoints — automatically. See
Tenant facet and Facets for
the broader facet model.
// 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
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.
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
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 for the full column list.
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 for the full
attribute schema.
Cross-tenant cache pollution is prevented by construction
The cache 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 for the fingerprint shape and the other three gaps (model identity, prompt, tool surface).
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 toharness_event_logthat don't stamptenant_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 month's invoice is one query.
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
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.
-- "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 for the spawn lifecycle.
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.
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 for the
broader isolation pattern and Recipes (recipe
10) for the per-sub-tenant rollup query.
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.
// 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
for the policy contract.
Project layout
The biggest delta from the baseline:
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 readsWhat changes from the baseline:
runtime.tsexports a factory, not an instance. Every request resolves a tenant and builds aSessionRuntimewith 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 rather than by a runtime check.tenant.tsruns 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.tsis 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.tsthreads 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/*.sqlship 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.
The audit:tenant-scoping gate makes
the directory layout enforceable — a storage read without a
tenant predicate fails CI, not production.
Where to go next
runtime.tenant facet
The primitive reference — `tenantId` + `subTenantId` accessors and the type-level surface.
Multi-tenant deployments
The operations guide — RLS templates, per-tenant cost rollups, deployment checklist.
OTel observability
The pleach.tenant_id span attribute and the rest of the OTel schema.
Audit ledger
The harness_auditable_calls schema and the indexes the rollups read from.
Subagents
SpawnTreeState and how nested fan-out gets attributed to the parent turn.
Auditable call row
The full column list, including tenantId and the payload JSON schema.
Internal knowledge agent
A retrieval-grounded agent over your private docs — with per-chunk provenance in the audit ledger and a "no chunk, no answer" safety policy.
Regulated-domain agent
An agent built for HIPAA, SOC 2, PCI, FedRAMP, or 21 CFR Part 11. Constrained routing, PII gate, append-only ledger keyed for the regulator's query.