pleach
Cookbook

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 tenantId on 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:

FieldSourceWhy finance cares
tenant_idruntime contextinvoice partition
turn_idruntime, per user turnunit of work
tool_nameruntime, per tool callwhat the model actually did
subagent_depthruntime, per spawnnested fan-out attribution
model_idresolved at call timerate card lookup
token_usageprovider responsecost 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 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 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 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 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.

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

On this page