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.