# The Agent Core on Its Own # Book: Pi Agents # Chapter URL: https://programmer.ie/books/pi/37-chapter/ # Prose fingerprint (sha256, normalized): d3a9e8230f99a909136cce908f56399d7db83ff35d35eacbe1ce10128512c4a5 # Snapshot generated at build time by Hugo. This is the complete rendered prose # of the chapter; it is not a transcript of the source file. The claim checker from chapter 36 has a limit. Given the evidence you handed it, it answers. Given too little, it says insufficient and stops, because a single call cannot go and look for more. Looking for more is a loop: the model asks for something, you run it, you give the result back, and the model decides again. You have seen that loop for thirty-six chapters, from the outside. This chapter runs it from the inside, in about forty lines, using the same class the coding agent is built on. Not the coding agent A naming point first, because it will save you a wrong turn. pi-agent-core is Pi. It is the package pi-coding-agent builds on, and the loop in chapter 1 is its code. What this chapter leaves out is pi-coding-agent: the sessions, the trust gate, the extensions, the terminal. So the title of this part is “Below the Coding Agent”, not “Without Pi”. Its published dependencies are @earendil-works/pi-ai and typebox, and nothing else. There is no terminal library, no filesystem layer and no provider SDK in the list. That is the documented fact the layer claim in chapter 1 rests on, and it is why the program below runs in a plain Node.js process. flowchart TD subgraph CORE["pi-agent-core — zero interface imports at 1.0.4"] L["the loop<br/>turns, hooks, events, queues"] end subgraph AI["pi-ai"] M["one request<br/>auth, payload, stop reason"] end APP["your code<br/>the tools, the prompt, the state"] --> L L -->|"streamFn: a function, not an import"| AI L -.->|"no terminal, no files, no provider SDK"| N["everything above is yours"] The dashed edge is the whole chapter. Nothing in the core reaches a terminal, a filesystem or a provider; everything the agent application is — sessions, context files, skills, trust, the interface — is code you wrote above this line. What an Agent is made of The dependency claim above is checkable, and it is worth knowing how to check it. At pi-agent-core 1.0.4, every module specifier appearing in the package’s dist/ is one of @earendil-works/pi-ai, typebox, or a relative path within the package. Two declared runtime dependencies, and zero interface imports. The transitive graph closes it too: pi-ai depends on provider SDKs, telemetry and schema libraries, and on no UI or TUI package. That is a countable property of a pinned release, which is why the chapter states the version with it — the number is only stable because the pin is. Chapter 1 rests its layering argument on it and appendix A records the measurement. The Agent constructor takes two things that matter. A model, and a stream function: new Agent({ initialState: { systemPrompt, model, tools }, streamFn: models.streamSimple.bind(models), }); streamFn is how the core reaches a model. It is a function from a model and a transcript to an event stream, and Models.streamSimple has that shape. This is the seam between the layers: the agent core does not import a provider, it is handed a function. Replace the function and you have replaced the provider, without touching anything above it. Pi ships one such replacement: streamProxy routes the calls through a server, for an application that must not hold a provider key. AgentOptions also has a transport field that is forwarded to the stream function, and a getApiKey hook that supplies a credential at request time. setDefaultStreamFn() exists for hosts that want to install one once, so that code constructing agents does not need to pass it. An AgentTool is a pi-ai Tool with additions. The two you always write are a label for display and an execute() that does the work. The declaration also has four optional fields: prepareArguments, executionMode, outputSchema and replay. The declaration half is exactly what chapter 36 gave the model. The execution half is what chapter 36 left out on purpose: execute: (toolCallId, params, signal?, onUpdate?) => Promise<AgentToolResult> params is already validated against the schema by the time execute() runs. To have TypeScript know the shape of params, type the tool with its schema: AgentTool<typeof LookupParams>, where LookupParams is the TypeBox object. Without the type argument the parameters are unknown, and an execute(_id, params: { query: string }) does not compile. That was found by compiling this book’s own examples, which is the reason they are compiled. You return content, which the model reads, and details, which your code keeps. If the tool cannot do its job it throws, and the core turns that into a tool result with isError: true. The declaration offers a second channel: a result may carry isError: true itself, “to report a failure without throwing”, and the model sees it as an error result. What is not failing is a result that describes the failure in content and calls itself a success, so the model has to read prose to discover that nothing happened. That is the same rule chapter 6 gave for bash and chapter 17 gave for extension tools. The coding agent did not invent it. It inherits it from here. Two of the optional fields deserve a sentence each. outputSchema with AgentToolResult.structuredContent is a declared, machine-readable result for programmatic callers, which is not sent to the model: the same idea as chapter 38’s typed exit, one level down. replay ("never" or "safe") is the tool’s own declaration of how an effect with unknown outcome may be recovered. It is a value the tool chooses about itself, which chapter 39 and chapter 42 come back to. The research agent Here is the claim-checking task with a way to look things up: import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"; import { Type, type Model, type Models } from "@earendil-works/pi-ai"; export interface Corpus { [id: string]: string; } // A tool is data plus one function. This one is read-only and pure, so it is // safe to run in parallel with itself. const LookupParams = Type.Object({ query: Type.String() }); export function lookupEvidence(corpus: Corpus): AgentTool<typeof LookupParams> { return { name: "lookup_evidence", label: "Look up evidence", description: "Return the corpus entries whose text contains the query. Use short keywords.", parameters: LookupParams, async execute(_id, params) { const q = params.query.toLowerCase(); const hits = Object.entries(corpus).filter(([, text]) => text.toLowerCase().includes(q)); const text = hits.length ? hits.map(([id, t]) => `[${id}] ${t}`).join("\n") : "no matches"; return { content: [{ type: "text", text }], details: { query: params.query, hits: hits.map(([id]) => id) } }; }, }; } // The agent core takes exactly two things from the outside: a model and a // stream function. The prompt, the tools and the transcript are ours. export function makeResearchAgent(models: Models, model: Model<any>, corpus: Corpus): Agent { return new Agent({ initialState: { systemPrompt: "Research the claim using lookup_evidence. Answer in one sentence.", model, tools: [lookupEvidence(corpus)], }, streamFn: models.streamSimple.bind(models), }); } Three things about it are choices worth noticing. lookupEvidence() is read-only and pure. It reads a corpus you passed in and returns text. Because it has no effect on the world, nothing about the agent needs to guard it, and the tool is safe to run in parallel with itself, which is the core’s default for the tool calls in one assistant message. A tool can override that with executionMode: "sequential", and one sequential tool in a batch makes the whole batch sequential. Chapter 39 comes back to why that matters once a tool does have effects. A miss returns "no matches" as ordinary content. A search that finds nothing is an answer, not a failure, and the model should be allowed to reason about it. The second test pins that. The system prompt is one sentence. The core owns the prompt, the tools and the transcript, and initialState is the whole configuration. There is no AGENTS.md to discover and no APPEND_SYSTEM.md to merge. Those are coding-agent features, and chapters 18 and 28 are where they live. Running the loop const agent = makeResearchAgent(models, model, corpus); const seen: string[] = []; agent.subscribe((e) => { seen.push(e.type); }); await agent.prompt("Did the release ship?"); With the faux provider scripted to ask for lookup_evidence and then to answer, the test asserts the shape of what happened: const roles = agent.state.messages.map((m) => m.role).filter((r) => r !== "system"); assert.deepEqual(roles, ["user", "assistant", "toolResult", "assistant"]); assert.equal(faux.state.callCount, 2); // one request per turn That four-message transcript is the loop from chapter 1, with nothing between you and it. The assistant asked for a tool, the tool result went into the transcript as its own message, and the model was asked again. Two requests, because there were two turns. agent.state.messages is the transcript, and in the bare core it is yours to read and to assign — there is no session layer above it to disagree. Inside pi-coding-agent, do not assign to it. SessionManager owns the authoritative copy (chapter 34), and an assignment to the core’s transcript compiles, runs, and does not change what the session sends. That is observed, not proposed: chapter 34’s suite runs it, overwriting agent.state.messages with a single forged user message, prompting again, and asserting that the forged text never reached the provider while the real history did. So the same line is authoritative in one layer and inert in the other, and which layer you are in decides whether an assignment means anything. The leading system message is filtered out in the assertion because the core records the prompt and the tool declarations as a system message at the head of the transcript. Chapter 26 described the same structure on disk; this is where it is created. The events are the ones you already know, minus two agent.subscribe() delivers the lifecycle you met in chapter 33: agent_start, turn_start, message_start, message_update, message_end, tool_execution_start, tool_execution_update, tool_execution_end, turn_end, agent_end. In the core the listeners are awaited in registration order, and await agent.prompt() settles only after the listeners for agent_end have settled. Two names are missing, and the absence is informative. agent_before_settle and agent_settled are not in pi-agent-core’s types at all. They belong to the coding agent, because what they describe is the coding agent’s own extra work after the core’s run ends: automatic retry, overflow recovery, compaction, queued messages. When chapters 1 and 16 said that agent_end closed one low-level run and not the whole job, “low-level” meant this: the core’s run. The core has no recovery to wait for, so for the core agent_end really is the end. Whoever wraps it adds the rest, and adds the event that says the rest is over. Steering and follow-up are in the core, with the semantics chapter 25 described: agent.steer() delivers after the current assistant turn, agent.followUp() only when the agent would otherwise stop, and both have the "all" and "one-at-a-time" modes. Chapter 25’s terminal keys were an interface onto these two methods. What the host was doing for you Run the loop yourself once and the list of what the coding agent provides becomes concrete. Nothing below exists in this program. Capability Where it lives In this chapter The loop, messages, tool execution, events pi-agent-core Present Choosing the model and reaching it pi-ai + your streamFn You supply it A session file, a tree, /tree and /fork pi-coding-agent Absent: the transcript is an array in memory Compaction pi-coding-agent Absent: transformContext is the place to write your own Automatic retry and overflow recovery pi-coding-agent Absent Context files, skills, prompt templates pi-coding-agent Absent Built-in read, bash, edit, write pi-coding-agent Absent: the only tool is the one you wrote Project trust, tool_call extensions pi-coding-agent Absent: chapter 39 gives the core’s own hooks A terminal, JSON or RPC interface pi-coding-agent Absent: subscribe() is the interface Read the last column as the point. Whatever is absent is something you now decide, which is a cost, and something that cannot interfere with you, which is what you wanted when you left a plugin-heavy host. A bash tool that does not exist cannot be called by a model that goes wrong. Chapter 42’s first rung, “do not grant the capability”, is the default here — and chapter 42 numbers that as rung 2, not rung 1. Rung 1 is instruction; rung 2 is the capability simply being absent. Agent or agentLoop pi-agent-core also exports the loop as a function, agentLoop(), which returns an event stream. Use the Agent class unless you have a reason not to. The README is specific about why: the raw loop’s streams are observational and “do not wait for your async event handling to settle before later producer phases continue”. The Agent class makes message_end processing a barrier before tool preflight, so a beforeToolCall hook sees state that already contains the assistant message that asked for the tool. A guard written against the raw loop can race its own bookkeeping. What you can and cannot claim Documented: the Agent constructor options, streamFn, AgentTool as an extension of Tool, the event names, awaited-listener behaviour, throw-to-fail, parallel tool execution as the default, steering and follow-up, and the dependency list (pi-agent-core README and declarations, 1.0.4). Observed: the transcript shape and request count in examples/ch37-agent-core/, under scripted responses, on 1.0.4. The absence of agent_settled from the core’s types is a reading of the declarations, not a runtime observation. Proposed: the “host was doing for you” table is this book’s summary, assembled from the docs of both packages. It is not a table Pi publishes. Next The agent works, and it will do whatever the model asks of it, in whatever shape the model answers. Its answer is prose. Chapter 36 spent its length forcing a typed result out of one call, and a loop makes that harder, because the model now decides when it is finished. Chapter 38 closes that gap: an agent whose only way to finish is to hand back a value that matches a schema.