This guide has two parts: (1) injecting the logged-in user's identity into the agent via sealed token metadata (the recommended multi-tenant pattern), and (2) scoping each tenant's dashboard to exactly the calls it may see.
One shared agent + per-user session (sealed token metadata) — recommended#
You usually do not need a separate agent per tenant. Run one shared agent and inject the logged-in user's session into each call as sealed token metadata — the identity rides inside the token, signed by your server, so the browser can't forge or alter it. This is the cleanest way to make a single agent multi-tenant + per-user.
How it works#
createToken(channel, agentId, metadata) bakes a metadata object into the token at
mint time (server-side, trusted). It arrives in your agent as call.metadata —
use it to scope every tool/query to that tenant and to fill the prompt with the user's
context. The browser never sees or sets it beyond the opaque token.
// ── 1. SERVER: mint a token with the signed-in user's session sealed in ──
// (behind your auth — the metadata comes from the SESSION, never the client)
app.post("/api/lumi/token", authMiddleware, async (req, res) => {
const token = await pc.createToken("chat", "lumi", { // ← 3rd arg = sealed metadata
companyId: req.auth.companyId,
userId: req.auth.userId,
role: req.auth.role,
userName: req.auth.name,
threadId: req.body.threadId, // e.g. to restore a conversation
});
res.json(token); // { token: "cht_..." }
});// ── 2. BROWSER: connect via tokenProvider — it returns the sealed token (voice/widget identical) ──
import { ChatSession } from "@pinecall/web/chat";
const chat = new ChatSession({
agent: "lumi",
tokenProvider: async () => {
const res = await fetch("/api/lumi/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // send your auth cookie/session
body: JSON.stringify({ threadId }),
});
return res.json(); // { token, server } — metadata is sealed inside
},
});
await chat.connect();// ── 3. AGENT: read call.metadata → scope tools + inject the session ──
const pc = new Pinecall();
const agent = pc.agent("lumi", {
prompt: `${SYSTEM}\n\n{{SESSION}}`, // {{SESSION}} filled per call
llm: "anthropic/claude-haiku-4-5",
tools: [listAppointments, bookAppointment], // each reads call.metadata (below)
history: myHistoryStore, // persist/restore per user+thread
preparing: true, // hold each turn while pushVars runs
});
// Fill per-session prompt vars from the sealed metadata before each turn.
// `await` it: with `preparing` set, the server holds the generation until this
// lands, so the values are the ones THIS turn is answered with.
const pushVars = async (call) => {
const m = call.metadata; // { companyId, userId, role, ... } — trusted
await call.setPromptVars({
SESSION: `<session><user>${esc(m.userName)}</user><role>${esc(m.role)}</role></session>`,
});
};
agent.on("call.preparing", pushVars);
agent.on("call.started", pushVars);
// The barrier is bounded, and a missed budget is an EVENT, not silence.
agent.on("call.preparingTimeout", (e) =>
console.warn(`turn ${e.turn} rendered with stale vars (${e.waitedMs}/${e.budgetMs}ms)`));// Tools scope by the SAME metadata — isolation lives in CODE, never the prompt.
const listAppointments = tool({
name: "list_appointments",
description: "List the tenant's appointments for a date.",
schema: z.object({ date: z.string().optional() }),
execute: async ({ date }, call) => {
const { companyId } = call.metadata; // sealed → trusted
return db.scope(companyId).appointments.forDate(date);
},
});Why metadata (not one-agent-per-tenant or the prompt)#
- Scales to N tenants with one agent — no per-tenant agent registration; identity is per call, not per agent.
- Trusted & unspoofable — the metadata is signed into the token by your server; a malicious client can't change
companyId/role. - Tenant isolation is enforced in code (tools scope by
call.metadata.companyId), never by trusting the prompt. - Prompt-injection safe — treat everything from
call.metadata(and any user text) as data: wrap it in clear tags (<session>…</session>), escape it, and tell the model in the system prompt to treat those tags as data, never instructions.
Sealed metadata works the same on every channel — mint with
pc.createToken("webrtc"|"chat", agentId, metadata)(oragent.createToken(channel, metadata)), then consume it in the browser via atokenProvideronnew ChatSession({ agent, tokenProvider })/new VoiceSession({ agent, tokenProvider }). It always surfaces ascall.metadata. ⚠️ The<VoiceWidget metadata={{...}} />/VoiceSession({ metadata })prop is the client-set, forgeable variant — fine for UI hints, but seal anything you authorize on into the token. SeecreateTokenand Conversation History (persist/restore per user via metadata).
Scoped dashboards: the stream token's agent set — recommended#
Each tenant owns one or more agents. When a tenant loads their dashboard, your backend mints a stream token listing exactly the agents that tenant owns — and that list is sealed into the token's signature, so the browser cannot add an agent to it.
Isolation is agent topology: one agent (or a few) per tenant, and a token
covers a call iff the call's agent is in its sealed set. A tenant holding
another tenant's call id gets a 403 — the id grants nothing. This works from
any topology (the dashboard reads the voice server directly, not your agent
process) and survives reconnects with cursor resume. See
Observe calls.
Building it#
1. Store the agent-tenant mapping#
In your existing app database, track which agents belong to which tenant:
// e.g. in your tenants table
{
id: "tenant_acme",
name: "Acme Corp",
agents: ["acme-support", "acme-sales"],
}2. Spin up the agents#
import { Pinecall } from "@pinecall/sdk";
const pc = new Pinecall({ apiKey: process.env.PINECALL_API_KEY! });
const tenants = await db.tenants.findAll();
for (const tenant of tenants) {
for (const agentId of tenant.agents) {
const config = await db.agentConfigs.findOne(agentId);
pc.agent(agentId, {
prompt: config.prompt,
llm: config.llm,
voice: config.voice,
language: config.language,
phoneNumber: config.phoneNumber,
});
}
}3. Mint one token per logged-in session#
The filter is the token, and it is sealed server-side. Nothing a tenant's browser can send widens it:
app.post("/api/observer-token", authMiddleware, async (req, res) => {
const tenant = await db.tenants.findOne(req.auth.tenantId);
if (!tenant?.agents?.length) return res.status(403).end();
res.json(await pc.createToken("stream", tenant.agents, undefined, { scope: "observe" }));
});Events from agents the tenant does not own never touch the wire — and unlike a per-request server-side filter, this one survives your web app restarting, because the browser reads the voice server directly.
4. Read the log in the dashboard#
import { useAgentCalls, useCall } from "@pinecall/web/log/react";
function TenantDashboard({ agent, token, server }) {
const { calls, live } = useAgentCalls(agent, { token, server });
const s = useCall({ call: live[0]?.call, token, server });
return <Transcript messages={s.messages} tools={s.toolCalls} custom={s.custom} />;
}Reconnects resume from the stored cursor, a page reload keeps the transcript
(reconnectOnMount, on by default), and a redeploy of your web app does not
blink the panel. The whole consumer surface — including the plain
EventSource version with no libraries at all — is
Observe calls.
5. Narrow the wire per tenant with types= / durable=1#
A tenant dashboard rarely wants word-by-word audio telemetry. Filter
server-side, per tenant, per panel — seq stays intact, so the cursor and
the resume still work exactly the same:
// the list panel: lifecycle only, no interim noise
useAgentCalls(agent, { token, server, types: ["call.started", "call.ended"], durable: true });
// a "what did our agent do" audit panel: tools and your own facts
useCall({ call, token, server, types: ["tool.call", "tool.result", "custom"], durable: true });// the same thing from a backend consumer
pc.observe({ agent, types: ["custom"], durable: true });durable: true drops ephemeral entries (partial transcripts, word timings) from
the live tail — usually a large majority of the volume on a busy voice tenant.
The always-pass set (log.gap, log.caught_up, call.ended, call.summary)
ignores both filters, so a filtered panel still knows where it stands and when a
call is over. Filters are a bandwidth tool, not an isolation tool: isolation
is the token's sealed agent set, above.
Per-tenant token endpoints#
The same pattern applies to WebRTC and chat tokens. Each tenant can only mint tokens for their own agents:
app.get("/api/token", authMiddleware, async (req, res) => {
const { agentId, channel } = req.query;
const tenant = req.cache.tenants.get(req.auth.tenantId);
if (!tenant.agents.includes(agentId)) {
return res.status(403).json({ error: "Forbidden" });
}
const agent = pc.getAgent(agentId);
const token = await agent.createToken(channel);
res.json(token);
});Per-tenant tool isolation#
Tools also need to be tenant-aware. Since tools are registered per agent, build them with a factory that closes over the tenant — each agent gets its own tenant-scoped tool:
import { tool } from "@pinecall/sdk";
import { z } from "zod";
function lookupOrderTool(tenantId) {
const tenantDb = db.scope(tenantId);
return tool({
name: "lookupOrder",
description: "Look up an order by ID",
schema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => {
return await tenantDb.orders.findOne(orderId);
},
});
}
// When spinning up each agent, pass its tenant-scoped tools:
pc.agent(agentId, {
prompt: config.prompt,
tools: [lookupOrderTool(tenant.id)],
});Scaling considerations#
A single Pinecall instance handles dozens to hundreds of agents on one WebSocket. For larger fleets:
- Split by region — run one
Pinecallinstance per geographic region, route tenants to the nearest - Split by tier — separate processes for free/paid tiers to isolate resource limits
- Split by capability — one process for voice-only tenants, another for WhatsApp-heavy tenants
What's next#
- Observe calls — the one way to read a call, with the filters and the resume rules
- The Call Log — the wire behind stream tokens
- Deployment topologies — where each mechanism applies
- Security — token model details

