Build the Kernel: Messages, Events, and the Agent Loop
Explore the process of building the core kernel of an AI agent harness called Wren. Learn how to manage messages, events, and the agent loop with minimal dependencies, ensuring robust interactions and predictable behavior. Understand how the harness coordinates conversations, handles tool calls, refusal logic, and event emission to maintain clear ownership and reliability across agent runs.
So far, we have described the contracts a harness should keep. This chapter starts with an empty directory and writes the smallest program that can keep any of them.
The finished harness is called Wren. It ends up at six files and under four hundred lines; it has no dependencies, and it runs on Node directly because current Node strips TypeScript types as it loads. There is no install step and no build step in this chapter.
This lesson builds three of those files. At the end, we run node run.ts and watch an agent take two turns, ask for a tool it does not have, and stop cleanly instead of pretending.
What is the smallest thing that is still an agent?
A loop that can change its next input based on what it just learned.
Everything else is a variation. Send a conversation to a model, get a reply back, and if the reply asks to do something, do it and append the outcome to the conversation. Send the longer conversation. Repeat until the model stops asking or until you stop it.
That loop is about forty lines. What makes it a harness is who owns each decision inside the loop, which is exactly what the previous chapters were about.
Which words does the harness need?
Fewer than you expect, and every one of them is closed.
types.ts holds the entire vocabulary. It imports nothing, and it knows nothing about HTTP, disk, or terminals, which is why every other file can depend on it without dragging anything along:
So what’s happening here?
Lines 4 to 11 are the conversation.
Messageis deliberately flat:textalways holds the words, and the two optional fields carry the only structure a conversation needs. Line 9’scallsappears only on an assistant message that asked for tools. Line 10’scallIdappears only on a tool message, and it is the string that ties a result back to the call that produced it.Lines 13 to 23 are one round trip through a tool. A
ToolCallleaves with anid, aname, and string arguments. AToolResultcomes back carrying that same id incallId, plus anokflag and the output. Line 21 is worth pausing on:ok: falseis a normal result, never an exception. A denied write and a missing file both arrive this way, which is why nothing in the loop needs atryaround tool execution.Lines 27 and 36 are the pair that matters most, and they look almost identical on purpose.
Stopon line 27 is why the provider stopped talking.Outcomeon line 36 is why the run ended. Keeping them apart is the difference between a harness and a wrapper: a provider sayingend_turnis a fact about a response, whiledoneis a judgment about a task, and only one of those belongs to the model. Watch for line 36 being computed by the loop and never read out of a reply. Both are unions, not strings. A free-form stop reason is a router that accepts anything, and the previous chapter’s graph lesson showed what a router that accepts anything does to a run.Lines 30 to 33 are the streaming unit: exactly three shapes. The code that consumes a stream is a three-branch switch with no forgotten case.
Lines 41 to 46 are what the outside world sees:
seqorders events,turngroups them,kindsays what happened, anddataon line 45 stays untyped on purpose because an event log has to survive a new field without a migration.
One practical note before you copy any of this for your local setup. Node strips TypeScript types without compiling them, so it rejects syntax that would need real code generated: parameter properties, enums, and namespaces. That is why the class in the next file assigns its fields in a constructor body instead of the shorter constructor(private replies: Reply[]). This is the tax for having no build step, and it is small enough to be worth paying.
Where does the model plug in?
Behind one interface, so the loop never learns which model it is talking to.
Let’s see what’s happening here:
Lines 5 to 7 are the entire seam. One method, two arguments, one return type. Everything above that interface is the harness; everything below it is somebody else’s API and somebody else’s outage. The
AbortSignalis in the signature rather than bolted on later because a provider that cannot be interrupted makes cancellation impossible for everything downstream of it.Lines 25 to 40 are the scripted implementation, and reading them tells you what any provider must do. Line 26 takes the next reply off the list.
Lines 27 to 30 handle running out of script by stopping cleanly, which is what keeps a test from hanging when the loop asks for one turn more than you wrote.
Lines 31 to 34 emit the text in sixteen-character chunks so the stream is genuinely a stream, and line 32 checks the signal between chunks, which is the only place cancellation can be observed cheaply.
Lines 35 to 38 emit the tool calls.
Line 39 emits the stop reason last, so a consumer always knows the text and calls are complete before it has to route on the reason.
That ordering is a contract, and a real provider has to honor it too.
ScriptedProvider is not a placeholder. It is the reason this chapter is testable: every run in these three lessons is deterministic, needs no key, and costs nothing. When you swap in a real provider, only send changes, and the sketch commented out on lines 47 to 52 is the whole difference.
The figure shows what each file may know about the others, and what stays behind the seam.
What does the loop actually do?
Six things, in a fixed order, and the order is the design.
Read it as a sequence of refusals, not a sequence of steps:
Lines 31 to 38 set the run-up. Line 32 is the seam this lesson leaves empty: with no
executepassed in, every call goes torefuseEverythingon line 24. Line 33 gives the run a turn budget whether the caller asked for one or not, and line 34 gives it a signal whether or not anyone will ever fire it, so the loop below never has to check whether these exist.Lines 40 to 49 are the two helpers everything else routes through.
emiton line 42 isasync, and line 43 awaits the listener, which is the single decision that lets the last lesson write a durable journal from outside the kernel.finishon line 46 is the only way out of this function, which is why no path can return an outcome without also emittingrun_end.Line 53 opens the turn loop, and its bound is the budget. Falling out of it at line 93 is not an error; it is the
budgetoutcome. A loop that can only exit by succeeding is a loop that hangs.Lines 56 to 59 drain one provider response, emitting text and call events as they arrive instead of after the reply completes, which is what makes the terminal feel live.
Now the refusals, in the order they fire.
Line 61 refuses a canceled run before looking at anything the provider said. Line 62 refuses to translate a provider error into a task outcome:
stop === 'error'becomesprovider_error, neverdone.Lines 64 to 68 append the assistant message before any tool runs, and that ordering is load-bearing. A crash between line 68 and line 84 leaves a transcript saying what was asked for, not that it happened. The last lesson of this chapter depends on that gap being visible.
Lines 73 to 76 are the most interesting refusal in the file. A
lengthstop means the reply was cut off at the token limit mid-sentence, so any tool call inside it may carry truncated arguments: a path missing its last segment, a command missing its closing quote. Wren refuses the whole batch and returnsblocked. Running half of a cut-off call is worse than running none of it, and this is the kind of rule that only appears in harnesses that have been run in anger. Pi has the same guard in its agent loop.Lines 78 to 81 are the ordinary ending. No tool calls, or a stop reason that is not
tool_calls, means the model is finished talking, and the run isdone.Lines 83 to 88 execute the calls in order. Line 85 emits the result before line 86 appends it to the transcript, so the log records the outcome even if the process dies before the conversation is updated. Line 87 re-checks the signal after every call because a long tool is exactly where a cancel arrives.
Lines 96 to 110 are
drain, the only place aDeltais interpreted. Text accumulates, calls collect, and line 107 keeps the last stop reason seen.
It is a fold over the stream, and keeping it in one small function is why the turn loop above reads as policy instead of parsing.
Why does an empty seam beat a stub?
Because a stub lies about what the program does, and an empty seam tells the truth.
Wren has no tools yet. The naive way to express that is to leave a TODO where tool execution belongs, which means this lesson’s loop is not the loop you will ship. Instead, the loop takes an Execute function, and when you do not pass one, it uses refuseEverything, which returns a failed result naming the missing tool.
So the loop is finished today. Tool calls flow all the way through it, produce a real refusal, and land back in the transcript as a tool message the model can read. The next lesson does not modify agent.ts at all; it passes a different Execute.
Trace one run through the two life cycles below. The turn boundary and the run boundary are different things, and most confusing agent behavior is somebody having conflated them.
Who is allowed to watch?
Anyone, and only watch.
The loop takes onEvent and emits a typed Event at every boundary. It is awaited, so a listener that writes to disk finishes writing before the run continues, which is what the last lesson of this chapter needs. Nothing in the loop reads an event back. Delete every listener and the outcome is identical.
That is the test of a real seam, and it is why the terminal UI arrives in the last lesson as thirty-seven lines that could be thrown away without touching the harness.
The events also decide what the harness can prove about itself later. run_start carries the task, result carries the outcome and output, and turn_end carries the assistant’s finished text, which is enough to rebuild the whole conversation from the log alone. A harness that logs a summary has to be trusted; one that logs the events can be checked.
Can you run it?
Yes, right now, with no key and no install:
$ node run.ts0 turn0 run_start {"task":"Find why CLIN-547 double-books the 3:00 PM slot.","maxTurns":8}5 turn1 call {"id":"c1","name":"read","args":{"path":"src/routes/appointments.ts"}}6 turn1 result {"id":"c1","name":"read","ok":false,"output":"no tool named read is registered"}14 turn2 run_end {"outcome":"done","turns":2}outcome=done turns=2 messages=4
In two turns, the agent asked to read a file, was told that no tool existed, and stopped instead of inventing the file’s contents.
Assemble the kernel yourself in the lab below, then break one seam at a time and watch which guarantee disappears.
How do you know the loop is correct?
You seed the failure and watch the loop catch it, the way the Harness Tests lesson argued.
Run them with node --test agent.test.ts. Six pass. Each one names a guarantee, not a function: a truncated reply executes nothing, the budget stops a provider that never finishes, an abort during a tool ends the run at that turn, and the outcome is the agent’s rather than the provider’s.
The last one matters most. A provider that returns error cannot make the run done because the loop never asks it.
What did the real harnesses need that this one skips?
Quite a lot, and the gap is worth seeing before you trust anything here in production.
Pi’s agent package is about 12,000 lines, and its loop alone is closer to 800 than 100: a second outer loop for steering messages typed mid-run, context compaction, a mutation queue so that two edits cannot interleave, and a retry policy per provider. DeepSeek Harness makes almost everything a plugin, so its equivalents of Provider and Execute are services that other packages register into.
Both keep the same two seams Wren has, which is the point of building the small one first. What Wren leaves out is a matter of degree. What it keeps is a matter of ownership, and that is the part a bigger version cannot add back later: a loop that lets the provider decide the outcome does not become correct when you give it compaction.
Break the ownership: In agent.ts, change the length guard to execute the calls anyway. Run the tests. One fails, and its name tells you what you gave away. Then delete the maxTurns check and point ScriptedProvider at a reply that always returns tool_calls. The loop no longer terminates. Both guarantees cost four lines each, and neither is something a better model provides for you.
What’s next?
Wren can hold a conversation, stream a reply, refuse a bad batch, and stop for four different reasons. It cannot read a file, run a command, or change a line of code.
The next lesson fills the Execute seam with three real tools and puts a single deny-by-default check in front of all of them, so the agent gets hands without the harness losing the authority boundary the course spent a chapter defining.