pinecallDocs
TypeScript / The view

The view

The prompt as named blocks in two regions, and render(), the JSX that writes the view.

The prompt is a list of named blocks in two regions, in this order, always:

region when it changes the blocks
static — before the history, cached by the provider never during a call identity (the class's docstring · the framework's rules and protocols · how to write on the call's channel and medium) · knowledge (the page the agent knows by heart, written by the gateway from its settings) · tools (every tool's name and docstring)
the history — the turns, the lookups, and the summaries a collapse left the runtime writes it; the app never does
dynamic — after the history, replaced every turn on every state change view — what render() says, the last thing the model reads

There are four blocks, the same four for every agent. The cut between the two regions is where the provider's cache is cut, and each block is sent by name and only when its own text changed: a when that opens a tool rewrites tools and nothing else, and the provider reads identity and knowledge back from its cache.

Every word of every block is yours. Nothing that arrived from outside the conversation is ever put in one — not a fact memory kept from an earlier call, not a chunk of a document. Those reach the model as a tool result, in the history, which is the place both vendors name for content a model should read as information and not as an instruction.

The view is render()#

render() returns JSX that renders to text, never to a page. this is the state: the fields, the getters, this.call and this.remembers(…).

TypeScript
override render() {
  return (
    <>
      {this.stage === "identify" && <p>Greet the caller and ask for their name and phone.</p>}
      {this.identified && <p>You are talking to {this.patient!.name}, already on file.</p>}
      {this.remembers("usual doctor") && <p>Offer their usual doctor's slots first.</p>}
      {this.call.channel === "phone"
        ? <p>Offer at most two slots.</p>
        : <p>Show up to five slots, one per line.</p>}
    </>
  );
}
you write the model reads
<p>Ask for their name.</p> one paragraph; line breaks and indentation inside it become single spaces
two siblings two paragraphs, one blank line apart
null, false, undefined nothing
a string or a number itself
<>…</> its children, with nothing added

So {condition && <p>…</p>} is all the control flow a view needs. A class with no render() sends an empty view. The view is rendered again after every state change, when the caller's turn lands, when memory recalls something, and when a page claims the call.

The tags#

Imported from @pinecall/agents:

tag renders as
<Rules> · <Protocols> their children one per line, inside <rules> … </rules> or <protocols> … </protocols>
<Rule> one line starting with -
<Section title="…"> ## title, a blank line, then its children as paragraphs
<Example> its children inside <example> … </example>, so the model reads an example and not an instruction

@render: the view beside the class#

A view that has grown past one screen can move out of the class. @render(Prompt) uses a function whose props are the instance itself:

TypeScript
import { Agent, render, type Stages } from "@pinecall/agents";

const SupportPrompt = ({ stage, customer }: Support) => (
  <>
    {stage === "identify" && <p>Ask for the order number. Nothing else until you have it.</p>}
    {customer && <p>You are talking to {customer.name}.</p>}
  </>
);

@render(SupportPrompt)
export default class Support extends Agent {
  stage: Stages<"identify" | "resolve"> = "identify";
  customer?: Customer | undefined;
}

A getter destructures like a field; a method does not, so call it on the agent. A class writes one spelling or the other, never both.

What the agent already knows about this caller#

this.remembers("usual doctor") answers whether memory has told this call something about the caller under those words. The runtime supplies the facts; a render nobody gave any — pinecall prompt, a test that says nothing about it — answers no rather than guessing.

It is a question, and that is the whole of what a view does with memory. The fact itself never appears in the prompt: it reached the model as the result of the platform's recall tool. What the view adds is the sentence you want said when the answer is yes. In a test, recalled(agent, ["their usual doctor is Dr. Vidal"]) gives the render something to answer from.

The channel's words#

The identity block ends in <channel>, how to write for this call: a voice reads everything aloud, a website's chat can take Markdown, WhatsApp has its own formatting. A class that writes its own formatting rules turns it off with channelRules = false; this.call.medium is still there for a view that branches on voice or text.

Reading the prompt#

pinecall prompt prints it with no gateway, no key and no network — the prompt is a function you can call. One section per block, ── identity (static) ── … ── history ── … ── view (dynamic) ──, so which half is cached is visible at a glance:

Terminal
pinecall prompt                                              # the state a call opens in
pinecall prompt --state test/<slug>/goldens/<a golden>.json  # the state a golden opens in
pinecall prompt --channel web --medium text                  # the prompt a written chat gets

In a test it is the same function:

TypeScript
import { promptOf, showPrompt } from "@pinecall/agents";

promptOf(agent).blocks.find((block) => block.name === "view")?.text;   // one block, by name
showPrompt(agent);                                                     // the page, under its headers

Collapsing a long call#

TypeScript
this.collapse("The patient is identified and has heard Tuesday's slots.");

The state is untouched. What collapses is the record of how it got here, which is what a long call runs out of room for. The sentence lands in the history under a <!-- collapsed: … -->.