← Pi Agents

The Agent Core on Its Own

pi-agent-core's Agent class is Pi's loop with the coding agent removed: this chapter runs the claim-checking task as an agent with a tool and shows exactly what the host was providing.

The claim checker from chapter 36 has a limit. Given the evidence you handed it, it can produce a schema-valid assessment, including insufficient when the evidence is inadequate. It cannot independently fetch more evidence in that single call, and the model may still judge the evidence incorrectly.

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”.

At the pinned pi-agent-core 1.0.4 release, the package declares @earendil-works/pi-ai and typebox as its two direct runtime dependencies. It does not directly depend on a terminal UI or filesystem-based session host. Provider SDKs still exist transitively through pi-ai, and the stream function you supply can perform network I/O. The useful boundary is that the core does not itself choose a coding-agent interface, session store or provider adapter.

    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 built-in TUI, session files or provider adapter"| N["host supplies these capabilities"]
  

The dashed edge marks what the core does not implement for you. It does not start a terminal or discover project context files, but it does execute the tools you supply and invoke your streamFn, which can perform network I/O. Sessions, trust policy, resource discovery and user interfaces belong to the host you build around it.

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 an initial state, including the selected model, and a stream function that performs requests. This example also supplies a system prompt and executable tools through initialState:

new Agent({
	initialState: { systemPrompt, model, tools },
	streamFn: models.streamSimple.bind(models),
});

streamFn is how the core reaches a model. Models.streamSimple matches the required interface: given a model, a transcript and options, it returns an assistant-message event stream. Replacing the function changes the model-access path — for example, from direct requests to streamProxy through your server — without rewriting the agent loop. That is not necessarily a change of provider: provider and model selection remain separate choices. A proxy can keep provider credentials on the server only if you configure that boundary correctly. AgentOptions can also forward transport to the stream function and use getApiKey for request-time credentials. setDefaultStreamFn() lets a host register a default rather than pass one to every Agent constructor.

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 has passed the declared schema validation by the time execute() runs. That checks structure, not whether the caller is authorized to access the requested data or whether execution will stay within a resource budget. 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 optional fields deserve a sentence each. outputSchema declares the shape of AgentToolResult.structuredContent for programmatic consumers; that structured payload is not automatically model-visible tool text. replay ("never" or "safe") declares a tool’s policy for an effect with an unknown outcome. It is metadata for recovery logic, not a guarantee that executing an effect twice is safe or that the bare core will reconcile it automatically. Chapter 39 and chapter 42 return to this boundary.

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({ minLength: 1, pattern: "\\S" }) });

export function lookupEvidence(corpus: Corpus): AgentTool<typeof LookupParams> {
	return {
		name: "lookup_evidence",
		label: "Look up evidence",
				description: "Return up to five short corpus matches. Use short keywords.",
		parameters: LookupParams,
		async execute(_id, params) {
						const q = params.query.trim().toLowerCase();
			const matches = Object.entries(corpus).filter(([, value]) => value.toLowerCase().includes(q));
			const hits = matches.slice(0, 5);
			const lines = hits.map(([id, value]) => {
				const matchAt = value.toLowerCase().indexOf(q);
				const start = Math.max(0, matchAt - 120);
				const excerpt = value.slice(start, start + 500);
				return `[${id}] ${start > 0 ? "…" : ""}${excerpt}${start + 500 < value.length ? "…" : ""}`;
			});
			if (matches.length > hits.length) lines.push("[Additional matches omitted]");
			const text = lines.length ? lines.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() has no write side effects: it reads an explicitly supplied corpus and returns a bounded excerpt. That makes repeated execution easier to reason about, and it can safely run concurrently against an immutable corpus. Read-only does not mean access-control-free: a real host must still decide which documents the agent may read and how much data to reveal. The core executes tool calls in a batch in parallel by default; a tool can request executionMode: "sequential", and one sequential tool makes that batch sequential. Chapter 39 explains the consequences for tools with side 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. Your application supplies the prompt, model, tools and starting transcript; the core maintains the resulting in-memory state and executes the loop. initialState is not a substitute for the coding agent’s resource loader: no AGENTS.md is discovered and no APPEND_SYSTEM.md is merged automatically. Those are coding-agent features covered in chapters 18 and 28.

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 in-memory transcript for this bare Agent, and you can replace it when the agent is idle. Treat such replacement as restoration of trusted, correctly shaped history — not as a safe way to edit an active run. Unlike a coding-agent session, this example has no SessionManager that will reconstruct a different canonical transcript before the next request.

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: agent_before_settle and agent_settled are not pi-agent-core event types in 1.0.4. They are coding-agent lifecycle events for work that can continue after a low-level run, including its retries and recovery. The core itself already processes its steering and follow-up queues before its final agent_end; that event marks the end of the core invocation, including its awaited subscribers. A coding-agent host may then do further work, which is why its agent_settled is the stronger completion signal for SDK and RPC clients.

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 can prune request context, but summarization, persistence and recovery are yours to implement
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. Every omitted component is functionality you do not have to operate, but also functionality your application must supply if it needs it. With no bash, edit or write tool registered, the model cannot invoke those tools through this agent. That is chapter 42’s second security rung: do not grant the capability. It does not sandbox the host process or prevent your own tool implementations from reading sensitive data or causing other effects.

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: examples/ch37-agent-core/ verifies the transcript shape, two provider calls, and the scripted lookup/no-match paths against 1.0.4. It does not measure whether a real model chooses the right search query, uses evidence faithfully, or stops reliably. The absence of agent_settled from the core’s types is a declaration-level finding, 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, but only the tools you registered can be executed through its tool interface, and your tool implementations still determine what effects are possible. Its ordinary final answer is prose. Chapter 36 obtained a schema-validated value from one call; in a loop, the model also chooses when to stop, so producing a reliably typed final result takes additional design.

Chapter 38 closes that gap: an agent whose only way to finish is to hand back a value that matches a schema.