Pinecall

Multi-Tenant Dashboards

Host many tenants on one Pinecall instance with scoped event streams.

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.

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) (or agent.createToken(channel, metadata)), then consume it in the browser via a tokenProvider on new ChatSession({ agent, tokenProvider }) / new VoiceSession({ agent, tokenProvider }). It always surfaces as call.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. See createToken and Conversation History (persist/restore per user via metadata).

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 Pinecall instance 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#