Tutorial
Forty minutes from an empty directory to a TypeScript agent that answers from your documents, remembers who called, and ships.
Forty minutes, from an empty directory to an agent that picks up a call, answers out of a folder of Markdown you wrote, and knows on the second call what it learned on the first.
You need the gateway — Pinecall's, at https://cloud.pinecall.io, whose sandbox is yours to break —
the one pinecall CLI, and this package. The CLI is a Node program for every language: it never
loads your class, it starts your project's @pinecall/agents/serve and talks to it through the
gateway. Your code never imports LiveKit.
1. The CLI, and a key#
npm i -g pinecall # the CLI, Node 24+, once per machine
pinecall new clinica-norte
cd clinica-norte
npm install # @pinecall/agents, the project's one dependency
pinecall link # signs this machine in, picks the org, writes its key to ./.env
pinecall whoami # which gateway, which org, and where the key was read
pinecall new writes the class, a ring-0 test, one golden and the toolchain: package.json,
tsconfig.json (extends: "@pinecall/agents/tsconfig.tenant.json", which brings the JSX runtime
of render() and the decorators) and vitest.config.ts.
pinecall link writes PINECALL_KEY into this folder's .env (and PINECALL_URL when the gateway
is not Pinecall's), which nothing else reads: another org is another folder. Every verb works in the
sandbox unless --prod is said.
2. The class#
The project's layout is the CLI's, the same for every language: agents/<slug>/agent.tsx, its
tests under test/<slug>/. The folder's name is the agent's slug. Replace
agents/clinica-norte/agent.tsx with this — the whole thing:
import { Agent, state, tool, type Stages } from "@pinecall/agents";
interface Patient {
name: string;
phone: string;
}
/** You are the front desk of Clínica Norte. Formal, short sentences. */
export default class ClinicaNorte extends Agent {
@state stage: Stages<"identify" | "resolve"> = "identify";
@state({ pii: true }) patient?: Patient | undefined;
/** Finds the patient by name and phone. Ask for both before calling it. */
@tool({ stage: "identify", pii: ["name", "phone"] })
async findPatient(name: string, phone: string): Promise<Patient> {
this.patient = { name, phone };
this.stage = "resolve";
return this.patient;
}
override render() {
return (
<>
{this.stage === "identify" && <p>Greet the caller and ask for their name and phone. Nothing else until you have both.</p>}
{this.patient && <p>You are talking to {this.patient.name}, already on file. Do not ask for them again.</p>}
</>
);
}
}
Four things are worth naming, because they are the whole design:
- The class's docstring is the prompt's first paragraph. Not documentation about the code: the words the model reads.
- A tool's docstring is what the model reads about that tool, and its signature is the schema.
The parameters' types become the JSON Schema the model fills, and what it sends is checked
against it before your method runs.
piinames the arguments that carry personal data and are masked in the log; one naming a parameter the method has not is refused when the class loads, by name — as is a tool with no docstring. - Fields are the state, and tools are the only writers.
this.patient = …renders the prompt again and writes astate.changedline carrying the tool's own name; the same assignment anywhere else throwsUnauthoredWrite. - The view is
render(): JSX that renders to text, with the state inthis. It is the only part of the prompt that differs between two turns of a call.
Before running anything, look at what the model would read. No key, no gateway, no network:
$ pinecall prompt
── identity (static) ──
You are the front desk of Clínica Norte. Formal, short sentences.
<rules>
- Invent nothing: if it did not come from a tool or from the knowledge, do not say it.
- One question per turn, and wait for the answer.
…
</rules>
<protocols>
- To act, call a tool; saying you have done something does not do it.
…
</protocols>
<channel>
You are on a phone call. Everything you write is read aloud by a voice: short spoken sentences, …
</channel>
── knowledge (static) ──
── tools (static) ──
<tools>
- findPatient: Finds the patient by name and phone. Ask for both before calling it.
</tools>
── history ──
── view (dynamic) ──
Greet the caller and ask for their name and phone. Nothing else until you have both.
Four named blocks in two regions, in the one order they are ever sent. Everything above history is
what the provider caches; the view is rendered again on every state change, and a block goes up
again only when its own text changed. The rules and the channel's words are the framework's, the
same in every language the SDK is written in. pinecall prompt --state <a golden's file> prints the
page that golden's opening state produces.
3. Talk to it#
pinecall chat # the agent served from this terminal, and a written caller against it
pinecall start # registered and answering: the process you deploy
chat starts your project's serve entry for the agent of this folder — a process that takes no
call it did not open — opens a written call naming that process, and stops it when you leave.
start is the same entry taking every call, with the console's screens answered beside it; under
it, the console's Talk tab is a real voice call to the same process. --inspect opens that process
to a debugger, so a breakpoint in a @tool is reachable.
Your tools run in that Node process: the gateway asks, your method answers, and no code of yours ever crosses the socket.
4. Knowledge: a page the agent knows by heart#
The voice, the model, the opening and everything else the agent runs on are not in the class: they
are the world's, set with pinecall agent set or the console's Settings, and a class that still
declares one is refused when it loads, with the verb to use instead
(Writing an agent).
What the agent knows by heart — hours, prices, what needs an authorisation — is one of those: a page
of Markdown in the agent's settings, written in the console, Settings ▸ Knowledge, or with
pinecall agent knowledge edit. The class sends nothing for it, so pinecall prompt prints the
second block empty; the gateway writes the page into it, whole, once per call, in the cached prefix:
the model has it in every turn and you pay for it once.
Use this for what is small, stable and always relevant. A folder of a hundred documents is not, and that is the next step.
5. The knowledge base: what it looks up per turn#
Put your Markdown under docs/clinica-norte/, one file per subject, with headings, and push it.
With no arguments the verb reads that folder and pushes it under the agent's slug. Attaching the
base to the agent, with its k and min_score, is a setting — or the class's static docs.
pinecall docs push
pinecall docs attach clinica-norte --k 4
That is all: no vector-database client, no search call in your code, no if that decides when to
look. Each file was cut at its headings, each chunk prefixed with its heading path
(prices.md › Prices › Check-up) and embedded; on every turn the platform runs a search itself,
over an HNSW index and a BM25 index fused by rank. On a spoken call it starts while the caller is
still talking, so the answer is there when they stop.
A push replaces the base whole, so push again after every edit.
6. Memory: what it keeps between calls#
pinecall memory policy --remember "how they like to be addressed" "allergies" "their usual doctor" --forget "payments"
The policy is a setting, or the class's static memory, which wins over it. remember is the vocabulary, in your own words, of
what is worth keeping about a person; forget is what is never written whatever the model heard.
A recall runs beside the search on every turn; at hang-up, one model call reads the call and
writes what it taught. On the phone and on WhatsApp the number is the identity; on the web nobody is
anybody until your page says so.
The view may ask memory a question and say a sentence of its own about the answer:
{this.remembers("usual doctor") && <p>Offer their usual doctor's slots first.</p>}
7. What the model actually receives#
system: identity · knowledge · tools ← cached, unchanged while the call runs
messages: …the turns…
assistant tool_use recall {"contact":"+34600123456","query":"How much is…"}
user tool_result {"facts":[{"text":"Prefers to be called Marta.", …}]}
assistant tool_use search {"query":"How much is a general check-up?"}
user tool_result {"chunks":[{"path":"prices.md", "heading":"Prices › Check-up", …}]}
user <instructions> what render() wrote </instructions>
A remembered fact was written by a model from an earlier caller's words, and a chunk was written by whoever wrote the document. Neither is yours, so neither carries your authority: they arrive as tool results, JSON-encoded, never as a paragraph of the prompt. That is why every block of the prompt is your own words and nothing else is ever put in one.
8. The log, and the console#
pinecall console # the sandbox's console, signed in as this folder's key
pinecall sessions # the calls this agent has taken; `sessions show <call>` reads one
The same log without a browser is @pinecall/agents/client:
import { Pinecall } from "@pinecall/agents/client";
const pc = new Pinecall({ url: process.env.PINECALL_URL ?? "https://cloud.pinecall.io", apiKey: process.env.PINECALL_KEY });
const page = await pc.history({ call: process.argv[2]! });
for (const entry of page.entries) {
if (entry.type === "docs.sources") console.log(entry.data);
}
Every call is an append-only log of typed entries, each with a seq written before control returns.
9. Test it#
Ring 0 is your own suite: vitest, no network, no key, no model, no gateway. Replace the test
pinecall new wrote, test/clinica-norte/agent.test.ts, with one for this class:
import { readFileSync } from "node:fs";
import { CallWorld, describe as describeClass, promptOf, runTool, seal, setCall, toolNamed, type ToolDeclaration } from "@pinecall/agents";
import { expect, it } from "vitest";
import ClinicaNorte from "../../agents/clinica-norte/agent.js";
describeClass(ClinicaNorte, readFileSync(new URL("../../agents/clinica-norte/agent.tsx", import.meta.url), "utf8"));
it("stops asking for the name once the patient is identified", async () => {
const agent = seal(new ClinicaNorte());
setCall(agent, new CallWorld({ id: "CA_1", contact: "+34600123456", channel: "phone" }, () => undefined));
await runTool(agent, toolNamed(agent, "findPatient") as ToolDeclaration, { name: "Marta", phone: "600123456" });
const view = promptOf(agent).blocks.find((block) => block.name === "view")?.text ?? "";
expect(view).toContain("talking to Marta");
expect(view).not.toContain("ask for their name");
});
npm test
describe hands the class its own source, so its docstrings survive the transpiler; runTool runs
a tool as a call does. Testing is every helper.
Rings 1, 2 and 3 — the goldens, a real line, and one call re-scored — are pinecall test,
pinecall simulate --voice and pinecall eval. Ring 4 happens without you: every finished call
is judged at hang-up and the verdict is an entry in your own log, call.score.
10. Ship it#
pinecall deploy --prod
Pinecall installs the project from its lockfile and runs it, a deploy never cuts a call, and
Production is the other two ways: inside your own Node app, or
pinecall start --prod on a server of yours.
Where to go next#
| you want | read |
|---|---|
| every declaration a class may carry | Writing an agent |
the four blocks, the two regions, and render() |
The view |
| the rings, and what is worth a test | Testing |
| running it on a server | Production |
| a whole agent: stages, ids, a confirmed booking | ../examples/clinica-norte |