Tokens
How a browser and a phone get a token, what each may do with it, and the log token for one call.
Where a tenant's backend mints a browser's token. It implements LiveKit's standard token endpoint
(docs.livekit.io, authentication endpoint),
so a stock livekit-client with TokenSource.endpoint(url, { headers }) joins the agent's room
with what it answers, and no Pinecall client code is involved. LiveKit's contract, in short: a
POST with optional room_name, participant_identity, participant_name,
participant_metadata, participant_attributes, room_config; 201 with server_url and
participant_token; the endpoint adds its own authentication and refuses with a 4xx the fields
a client may not set.
What this runtime adds to LiveKit's mint#
- The org check. The endpoint takes the org's key holding
talk(the tenant's backend holds it; the browser never sees it) and mints only for an agent somebody holds in the key's org and environment. Nobody holding it is404, since a token for one is a browser joining a room nothing answers in. The token'sroom_configis one dispatch to the fleet of the key's environment, its metadata naming the agent, the org, the environment and the holder, so the one worker every org shares asks the gateway for that org's endpoints, declaration and keys. - The contact id, and nothing PII.
participant_metadatacarries the org's opaque contact id (contactin the body) and nothing else. A name is refused: the log would carry it. What the backend seals inmetadatarides the signed dispatch; the browser reads its token and changes none of it. - One dispatch opens one call, where the token was minted. The call the token names is
written into a ledger when it is minted, with the org, the environment and the agent it was minted
for, and the dispatch that opens the call spends it: the gateway asks the ledger by the call's
id at every
POST /v1/calls, whatever the worker says. A worker that names another org, environment or agent is answered404, as for a call nobody opened, and the token is left unspent. A join that creates the room again after the call ended is refused with409andtoken_spenton the agent's log. A second join with the same token while its call is live is bounded by the TTL alone, since LiveKit reads a token's room config only on the join that creates the room: keep it short.
The body#
| field | whose | meaning |
|---|---|---|
agent |
ours | the agent's slug, or room_config.agents[0].agent_name as a stock client sends agentName |
scope |
ours | talk (default): publish the microphone, hear the agent. chat: the same room with no microphone. Both write on the call's data channel, the caller's own words; observe and supervise never do. Any other is 400: observe and supervise take the API key |
contact |
ours | the org's opaque id for the person; becomes participant_metadata |
metadata |
ours | JSON sealed into the call, reaching the worker in the signed dispatch |
ttl_s |
ours | 60 by default, 600 at most |
log |
ours | what the answer's log_token reads through: public (default) or tenant (Projections) |
participant_identity |
LiveKit's | who the browser joins as; web_<12 hex> when absent, and the call's from |
participant_attributes |
LiveKit's | published verbatim; the pinecall. prefix is ours and refused |
room_name · participant_name · participant_metadata |
LiveKit's | refused, 400, each with its reason |
The answer is 201 with LiveKit's two fields and two more every SDK ignores:
{ "server_url": "wss://cloud.pinecall.io", "participant_token": "eyJ…", "call": "CA_5f1c…", "log_token": "eyJ…" }
server_url is the LiveKit of the token's environment: the platform's name (PINECALL_DOMAIN) over wss,
and for the sandbox wss://<the name>/sandbox when it has a LiveKit of its own
(LIVEKIT_SANDBOX_URL); on a deployment with no name, LIVEKIT_PUBLIC_URL, else the environment's LiveKit.
call is the room and the id
its log will have, so the backend can watch it without decoding a JWT.
The log token#
The participant token dies in a minute; a page shows a call longer than that and after it ends.
log_token opens no room (room_join, publish and subscribe all false), carries
pinecall.scope: read and the projection asked, and lives four hours. It reads that one call's
GET /v1/calls/{call}/events, /state and /recording, as ?token= or as the bearer, before and
after the call ends; another call, an agent's log or a supervisor verb is 403. Those three endpoints
answer any origin (GET, no credentials): the token the page brings is what opens them.
POST /v1/agents/{slug}/dial answers one too.
Refusals#
400 a scope this endpoint does not mint, a field that is ours, a pinecall. attribute, no agent
named · 401 no key of ours · 403 a key without talk · 404 nobody holds that agent · 422 a
ttl_s past 600 · 429 a quota, in its own sentence · 503 every worker of the fleet is full:
fleet.full on the agent's log and a sentence naming POST /v1/callbacks.
A stock client#
import { Room, TokenSource } from "livekit-client";
const tokens = TokenSource.endpoint("https://clinica.example/token"); // the tenant's backend
const room = new Room();
await room.connect(...(await tokens.fetch({ agentName: "clinica-norte" })));
The backend's /token forwards the body to POST /v1/tokens with Authorization: Bearer pc_live_…, adding what it knows: contact, metadata.