agent from the browser, watch every call live, with no code (The run console). It is a development tool. Build your own when you need your design, your auth and your data next to the call — this guide is how.
This guide walks through Dental Desk — the finished code is
dental-desk/ in pinecall/examples:
- an agent that answers a phone number and browser calls, and books appointments with a tool;
- a console: the clinic's settings, its archive, and every call as it happens — transcript, tool calls, who is speaking, and the bookings the agent made inside the timeline;
- as one process, one
.env, four nouns. Splitting it later changes almost nothing, because the live panel never depended on sharing a process.
patients ☎ 🎙 ───▶ voice.pinecall.io ───▶ the call's log (seq, append-once)
▲ │
│ SDK websocket │ GET …/events
│ │ Accept: text/event-stream
src/agent ──call.log()─────────┘ │
│ ▼
└──▶ src/clinic ──▶ db.json the browser
the rules the archive useAgentCalls + useCallTwo roads, and they never cross. Settings travel inward through the app's own bus; everything about a call travels through the call log, which the browser reads directly.
0. The layout — four nouns#
dental-desk/
├── src/
│ ├── clinic/ THE BUSINESS — pure rules, zero I/O: appointments.ts · settings.ts · calls.ts · events.ts
│ ├── storage/ HOW IT PERSISTS — Store (interface) · JsonStore · MemoryStore · index.ts (the composition root)
│ ├── agent/ THE VOICE — prompt.ts · tools.ts · config.ts · wire.ts · index.ts
│ ├── web/ THE REACT ROUTER APP — routes/ · components/ · lib/token.ts
│ ├── bus.ts a typed emitter — one event wide: "settings"
│ ├── config.ts SLUG · PHONE · PORT · DB_FILE
│ └── server.ts THE process: the agent boots, Express fronts React Router
└── server.js chooses Vite or the build, opens the portgit clone https://github.com/pinecall/examples && cd examples/dental-desk
npm install && cp .env.example .env # PINECALL_API_KEY, a dev- slug, no phone yet
npm run dev # http://localhost:3000The dependency rule is web → agent → clinic → storage, every arrow pointing
inward, enforced by one ESLint override per folder. See
Project structure.
1. The business, and the tool that calls it#
src/clinic is plain functions over plain data — no Pinecall, no React, no
disk:
// src/clinic/appointments.ts
export function book(rows: readonly AppointmentRow[], input: NewAppointment): AppointmentRow {
if (!availability(rows, input.date).slots.includes(input.time)) {
throw new Error(`${input.date} ${input.time} is not available`);
}
return { ...input, reference: reference(input.date, input.time) };
}The tool is thin, and it does two things: it books, and it says so in the call.
// src/agent/tools.ts
const bookAppointment = tool({
name: "book_appointment",
description: "Book an appointment for a patient.",
schema: z.object({ date: z.string(), time: z.string(), patient: z.string() }),
execute: async (input, call) => { // ← the Call, second argument
try {
const booked = clinic.appointments.book(input);
call.log("appointment.booked", booked, { id: `${booked.date}T${booked.time}` });
return { booked: true, ...booked };
} catch (err) {
call.log("appointment.refused", { ...input, reason: err.message });
return { booked: false, reason: err.message };
}
},
});call.log(name, value, { id }) appends a durable custom entry to this call's
log — with its own seq, next to the transcript, persisted, replayed to a tab
that reloads. id is the slot, so a model that calls the tool twice for the
same appointment upserts one row instead of confirming twice.
The vocabulary is one type, so the browser can be typed against it:
// src/clinic/events.ts
export type CallLog = {
"appointment.booked": AppointmentRow;
"appointment.refused": { date: string; time: string; reason: string };
};2. The token route — the only thing your backend does for the panel#
The browser reads voice.pinecall.io directly. Your server's whole job is to
mint a short-lived, read-only token with the API key it already has:
// src/web/routes/api/token.ts
export const action = async ({ request }: Route.ActionArgs) => {
const scope = new URL(request.url).searchParams.get("scope");
return Response.json(
scope === "observe"
? await pc.createToken("stream", SLUG, undefined, { scope: "observe" }) // to WATCH
: await pc.createToken("webrtc", SLUG), // to TALK
);
};The call page mints the observe token in its loader, so the panel is
watching on the first paint; the route is what an expired tab comes back to.
scope: "observe" means exactly that: no microphone, no control verbs, no other
agent, and the API key never leaves the server.
3. The live panel — two hooks, chained#
// src/web/components/AgentLive.tsx — which calls exist, which are live
import { useAgentCalls } from "@pinecall/web/log/react";
export function AgentLive({ agent, token, server }) {
const { live } = useAgentCalls(agent, { token, server });
if (live.length === 0) return <p>Nobody on the line.</p>;
return live.map((row) => <LiveCall key={row.call} call={row.call} token={token} server={server} />);
}One <LiveCall> per live call, keyed by call id — two calls at once are two
panels, never one painted over the other.
// src/web/components/LiveCall.tsx — one call's content
import { useCall } from "@pinecall/web/log/react";
import type { CallLog } from "~/clinic/events";
export function LiveCall({ call, token, server }) {
// throttle and reconnectOnMount are left at their defaults on purpose:
// one render per macrotask, and a reload resumes from the stored seq.
const s = useCall<CallLog>({
call, token, server,
onCustom: (name, value, _entry) => { // three params — the arity matters
if (name === "appointment.booked") toast(`Booked ${value.reference}`);
},
});
return <LiveTimeline messages={s.messages} tools={s.toolCalls} custom={s.custom} phase={s.phase} />;
}⚠️
onCustomnarrows only at exact arity. Declare all three parameters —(name, value, _entry) => …. Drop_entryand TypeScript stops contextually typing the union of parameter tuples, andvaluewidens back tounknown.
LiveTimeline merges messages, toolCalls and custom by seq, which is
the whole point of the booking being a log entry: it draws between the tool call
that made it and the sentence the bot said next, not in a list off to one side.
Three things you get for free, and none of them are React:
- A page reload keeps the transcript.
reconnectOnMount(defaulttrue) parks{seq, ts}inlocalStorageunderpc:log:<call>and resumes from it. Nothing in memory replays; the cursor does the work. It is cleared whencall.summarylands and ignored after 24 h. - It is not this process's call. Any copy of the app answering the phone shows up here, and so does a call that started before the tab was opened.
- No WebSocket is opened.
transportdefaults to"auto"= SSE, degrading to the JSON cursor. Observing is a read. See Observe calls.
4. Talk — a call from the same page#
VoiceSession from @pinecall/web/core, with a token provider that calls the
same route:
new VoiceSession({
agent: SLUG,
tokenProvider: () => fetch("/api/token", { method: "POST" }).then((r) => r.json()),
});While you talk, the transcript grows word by word straight off the DataChannel — that is what this browser is hearing. The panel below it is the other view: the agent's own log, which includes the call you are on and every other one.
5. Settings — the road that is not the call log#
form action ──► Settings.update() ──► bus.emit("settings") ──► agent.update()One event, one process, one listener. pc.agent() hot-reloads: the next call is
born with the new voice, calls in progress are not disturbed, nothing restarts.
The clinic never emits it — Settings.update() returns the new settings and the
caller announces them.
That bus is one event wide on purpose. Everything about a call already has a log with a cursor, replay and history; re-broadcasting it through an in-process emitter would be a second, worse copy that dies with the process.
6. Run it#
npm run dev # http://localhost:3000Press Llamar, ask for an appointment on a free slot — and watch yourself
appear under En el agente ahora: the transcript, the book_appointment chip,
and the booking row between them. Reload the page mid-call: the transcript is
still there. Change the voice under Settings, save, call again.
PHONE is optional — without it, everything works in the browser. Set
DENTAL_DESK_SLUG=dev-<you> and leave PHONE empty while you develop, or your
laptop claims the production number (Dev mode).
npm run typecheck · npm run lint (the arrow) · npm test · npm run build.
What you did NOT build#
An /api/events route relaying an event bus. A "was it delivered" table. A
webhook receiver. A WebSocket. A second copy of the transcript in your database
so a reload has something to show.
The clinic's own archive (db.json, written by src/agent/wire.ts) is the
business fact. The call log is the live view, and the cursor is the protocol.
What's next#
- Observe calls — every way to read the log, and every default
- The Call Log — the wire this rests on
- Project structure — the four nouns, and what changes when you split
- Deployment topologies — embedded vs standalone vs headless
- Multi-tenant dashboards — scoping stream tokens per customer

