AI Harness Engineering · OpenClaw 4 of 7 · Anatomy of an Agent Gateway

The Agent Loop — From Message to Action

A message arrives. Moments later the agent has read your files, called three tools, and replied. What happened in between is the agent loop — a strict, serialized state machine: intake, context, model, tools, stream, persist. You'll build the loop itself, its hook pipeline, and the four ways it can end.

Prerequisites: Lessons 1–3. You know what an LLM and a tool call are, roughly. A little Python for the labs. No background in agent frameworks assumed.
9
Chapters
2
Simulations
3
Code Labs

Chapter 0: What Is "a Run"

The whole lesson in one breath. When you message your agent and it answers, the gateway runs a single, ordered piece of work called a run. Every run is the same six stages in the same order: intakecontext assemblymodel inferencetool executionstreaming repliespersistence. Everything else in this lesson is detail hung on these six stages.

In Lesson 1 we built the picture of the gateway: one daemon owns all the chat-app connections and fans messages to a single agent. We treated "and then the agent runs" as a black box. This lesson opens that box.

Start with what you actually experience. You type "summarize my last three commits and message the team." A second or two of nothing. Then text streams back: a summary, a note that it posted to Slack. In between, the agent read files, called a git tool, called a messaging tool, and composed a reply. None of that was magic. It was a tight, repeatable procedure — a state machine that walks through fixed stages and can only end in a handful of ways.

Let's name the six stages, because the names are the mental model:

The six stages of every run.
  • Intake — a request arrives ("run the agent on this message"); the gateway validates it and accepts the run, handing back a run identifier.
  • Context assembly — the gateway gathers everything the model needs to see: the conversation so far, the workspace, available skills and files.
  • Model inference — the model is called. It either replies in plain text, or asks to use a tool.
  • Tool execution — if it asked for a tool, the gateway runs that tool and feeds the result back. Then it calls the model again. This is the loop.
  • Streaming replies — as text is produced, it flows back out to your chat app token by token, not all at once at the end.
  • Persistence — everything that happened is written into the durable transcript so the next run can build on it.

Why insist on a fixed order? Because an agent that can call tools, read your files, and post to Slack is doing irreversible things in the real world. A clear, single-track procedure is how you keep that controllable: you always know which stage you're in, what's allowed to happen there, and what comes next. A run is not a free-for-all where the model does whatever, whenever. It is a disciplined walk through six rooms, in order, every time.

Notice already that one stage — tool execution — is special: it can loop back to model inference. The model asks for a tool, gets a result, then gets called again, maybe asks for another tool, and so on. That little cycle is the beating heart of "agentic," and we devote a whole chapter (Chapter 4) and a Code Lab to it. The other five stages are mostly straight-through.

Vocabulary, fixed for the lesson. A run is one execution of the agent on one inbound message: the full six-stage walk, start to finish. A session is the ongoing conversation a run belongs to — the durable transcript that many runs append to over time. Many runs, over the life of a chat, share one session. Keep these two words straight and the rest of the lesson clicks.
What is "a run" in the gateway?

Chapter 1: Entry & Acceptance

The first stage, intake, is shorter and more interesting than it sounds. When an operator (the CLI, the desktop app, an automation) wants the agent to run, it sends a request. In OpenClaw the request is an RPC named agent (and there's a sibling, agent.wait, we'll meet in a moment). The CLI's openclaw agent "..." is just a thin wrapper over the same call.

The gateway does three quick things: it validates the parameters (is this a well-formed request?), it resolves the session identity (which conversation does this belong to?), and then — here's the key move — it returns immediately with a small acknowledgement: a fresh runId and an acceptedAt timestamp.

Accepted is not finished. When the gateway hands back {runId, acceptedAt}, it is saying "I have taken your request and assigned it an identity. I will start working on it." It is not saying "here is the answer." The run might take fifty milliseconds or fifty seconds. The acknowledgement comes back in the same breath as the request, regardless. You get a receipt, not a result.

Why split acknowledgement from completion? Because a run can be slow — it might call a model, wait on a tool, call the model again. If the socket that submitted the run were forced to sit and wait for the whole thing to finish before it could do anything else, a single slow run would freeze the connection. By returning a receipt instantly, the gateway keeps that submitting channel free. The run proceeds in the background; the caller can submit other things, watch for events, or check back later using the runId.

This gives us two flavors of starting a run, and the difference matters:

Two ways to start a run.
  • agent — fire-and-forget. You submit, you get the runId receipt, you return at once. To see what happens, you subscribe to the event stream (Lesson 1's pushed events): the agent's text, tool calls, and the final reply all arrive as events tagged with your runId. This is what a live chat UI uses.
  • agent.wait — block for the result. You submit and the call holds, up to a timeout, until the run produces its final result — then returns that result directly. Convenient for a script that just wants the answer and has nothing else to do. (Its default wait is short — thirty seconds — a number that becomes the star of Chapter 6.)

Both start the same run through the same six stages. They differ only in how the caller learns the outcome: by watching events, or by blocking for the return. Hold on to that — the distinction between "the run" and "whoever is waiting on the run" is the subtle idea that Chapter 6 turns into a real bug-or-not-a-bug puzzle.

The gateway returns {runId, acceptedAt} the instant you call agent. What does that response mean?

Chapter 2: Serialization & the Session Write Lock

A run accepted is a run that will soon be writing — appending the model's words, tool calls, and tool results into the session's transcript. That raises a sharp question: what if two runs for the same conversation are in flight at once?

Picture it. You message your agent from your phone, then a second later from your laptop, before the first finished. Two runs, same session. Both want to read the transcript, think, and write back to it. If they interleave their writes, you get a corrupted transcript — half of run A's tool result spliced into the middle of run B's reply, sequence numbers tangled, a conversation history that no longer makes sense.

Runs are serialized per session. The gateway treats each session as a single-file lane. Within one session, runs go one at a time — the second waits for the first to finish before it starts touching the transcript. The unit of serialization is the session key: same key, same lane, strict order. There is no interleaving within a conversation.

How is "one at a time" enforced concretely? With a write lock on the session. Before a run is allowed to mutate the transcript, it must acquire the session's lock; it holds the lock for the duration of its write work and releases it when done. A second run for the same session that arrives mid-flight finds the lock held and waits its turn. This is the same mutual-exclusion idea we used in Lesson 1 to keep two gateways off one WhatsApp session — here it keeps two runs off one transcript.

Cross-process, with a timeout. Two details make this lock real rather than decorative. First, it is a cross-process lock — not just a flag inside one program's memory, but a claim other processes can see, so even a separate worker can't sneak a write in. Second, it has a timeout (default sixty thousand milliseconds — one minute). If a run grabs the lock and then dies or hangs without releasing it, the lock can't be held hostage forever; after the timeout it can be reclaimed, so the lane doesn't deadlock permanently on one crashed run.

Now the crucial scope: this serialization is per session, not global. Two runs for different sessions — your work chat and your home chat, say — have different session keys, take different lanes, and run in parallel with no contention. The lock only ever pits a run against another run for the same conversation. That's exactly the granularity you want: protect each transcript's integrity, without making the whole gateway single-file.

The full story of how these per-session lanes are organized — the queue, what happens to the waiting run, fairness — is Lesson 6. Here, hold one fact: one run per session at a time, enforced by a cross-process write lock with a sixty-second timeout, and different sessions run concurrently.

Two messages arrive for the same session before the first run finishes. What does the session write lock guarantee?

Chapter 3: Context Assembly

The run is accepted, it owns the session lock, and now it must give the model something to think about. This is stage two, context assembly: gathering everything the model needs to see before a single token of inference happens.

The model is stateless between calls — it remembers nothing on its own. Every run must hand it, fresh, a complete picture of the situation: who's talking, what was said before, what tools and files exist, what the agent is supposed to be. Building that picture is work, and it happens in a fixed set of steps.

What context assembly does, in order.
  • Resolve or create the workspace. The run needs a place to operate — a working directory, the session's home on disk. If it exists, use it; if not, create it.
  • Inject a skills snapshot. The agent's available skills (named capabilities it can invoke) are captured as a snapshot, so the model knows what it can do right now, not what was available an hour ago.
  • Assemble bootstrap and context files. Standing instructions, project notes, and relevant files are gathered and laid out for the model — the durable "who you are and what's around you" material.
  • Acquire the write lock. Before any of this becomes writes to the transcript, the run takes the session lock from Chapter 2, claiming its single-file lane.

Why snapshot the skills instead of letting the model query them live mid-thought? Because a run should reason against a stable world. If the set of available skills could shift halfway through a run, the model might plan around a tool that vanishes before it calls it. Freezing a snapshot at assembly time gives the whole run one consistent picture — the same reason we serialize writes: predictability beats a moving target.

Notice that acquiring the write lock lives here, at the end of context assembly — right before the model loop begins to produce things worth persisting. Reading and gathering can happen freely; it's the moment the run is about to commit to the transcript that the lane must be claimed.

The line we're drawing on purpose. How these pieces get woven into the exact prompt string the model receives — the ordering, the formatting, the token budget, what gets trimmed when it's too long — is the entire subject of Lesson 5 ("Context & Streaming"). Here we care about context assembly as a stage: the place in the run where the world is gathered and frozen, the lock is taken, and the run is now ready to think. The internals are next lesson; the position in the pipeline is now.

With the workspace resolved, the skills snapshotted, the files assembled, and the lock held, the run crosses into the part that makes it an agent. That's the next chapter — and the first Code Lab.

Why does context assembly take a snapshot of the agent's skills rather than letting the model query them live during the run?

Chapter 4: The Infer → Tool → Infer Cycle

This is the heart. Everything before it was setup; everything after it is delivery. The cycle is what makes an "agent" actually agentic rather than a one-shot chatbot.

Here is the whole idea in plain words. The gateway calls the model. The model returns one of exactly two kinds of thing:

The model's output is always one of two things.
  • A final text answer. "Here's your summary." The model is done; this is the reply. The cycle ends.
  • A tool call. "Call read_file with path X." The model isn't done — it needs information or an action first. The gateway executes the tool, appends the result to the transcript, and calls the model again — now with the tool's result in view.

That's it. Infer; if it's a tool call, run the tool and infer again; if it's text, stop. The model, seeing the tool result, might produce the final answer — or it might ask for another tool, and round we go. A run that reads three files and posts one message went around this cycle four times: three tool turns, then a final text turn.

Watch a run walk the loop. Each Step advances one turn; Run plays it out. The model node lights, a tool fires, its result returns, the model is called again — until a final answer or the cap.

The infer → tool → infer cycle

The center node is the model; around it sit the tools it can call. Press Step to advance one turn, or Run to play. Watch: the model emits a tool call (a tool lights up), the result returns, the model is called again — repeating until it produces a final text answer or hits the iteration cap. Toggle break the loop to see the bug where the gateway calls the model once and stops: the tool result never returns, so the agent answers blind.

Idle. Press “Step” to take one turn.

Two design pressures shape this loop, and both matter. First, order is strict: the model never runs while a tool is running; a tool result is always appended before the next inference, so each model call sees a complete, in-order history. Second, there must be a cap.

Why a cap is non-negotiable. Nothing stops a confused model from asking for a tool, looking at the result, and asking for the same tool again, forever — an infinite loop that burns money and never answers. So the loop carries a hard iteration cap: a maximum number of times around. Hit the cap and the run stops, even without a final answer. The cap is the seatbelt that turns "potentially infinite" into "bounded and safe."

Now build it. The lab below gives you a fake model that asks for two tools and then answers, a tools table, and an iteration cap. Your job is the loop itself — the few lines that decide, each turn, "tool call? run it and go again. text? we're done." Get those lines right and you've built the thing that makes an agent an agent.

In the loop, the model returns a tool call instead of text. What does the gateway do next?

Chapter 5: Hooks as Extension Points

The loop you just built is fixed in shape, but it is not closed. At well-defined moments, the gateway pauses and asks: does anyone want to look at this, or change it, before I continue? Those moments are hooks, and plugins attach to them. Hooks are how OpenClaw is extended without rewriting the loop — the loop's structure stays the same, but its behavior at each seam becomes programmable.

A hook is just a named point in the run where registered functions get called, in order, and can influence what happens. The gateway exposes a generous set of them, spanning the whole lifecycle:

The hook stages, across a run's life.
  • session_start / session_end — the conversation lane opens and closes.
  • message_received — an inbound message arrived, before it becomes a run.
  • before_model_resolve — just before deciding which model to use.
  • before_prompt_build — just before context is woven into the prompt.
  • before_tool_call — right before a tool runs. This one can intervene.
  • after_tool_call — right after a tool returns.
  • tool_result_persist — just before a tool's result is written to the transcript. This one can transform.
  • message_sending / message_sent — a reply is about to leave, and has left.
  • agent_end — the run is finishing.
(There's also an internal agent:bootstrap hook the gateway uses to set itself up.)

Most hooks are observers — they get to watch and react (log this, count that, notify someone). But two of them are load-bearing, because they can change the run, and those two are where the security and policy weight sits:

The two hooks that can change the run.
  • before_tool_call can DENY or REWRITE. Before a tool fires, this hook sees the call. It can deny it outright — the tool never runs (think: a policy plugin that refuses any delete_all). Or it can rewrite the call — hand back modified arguments (think: forcing a safe limit onto an unbounded search). The loop then proceeds with the hook's verdict, not the model's raw request.
  • tool_result_persist can TRANSFORM the result. Before a tool's output is written into the durable transcript, this hook can rewrite it — for instance, redacting a secret or an access token so it never lands in stored history. What persists is the hook's version.

This is a powerful pattern. The model is the planner, but the hooks are the guardrails bolted onto the loop. The model can ask for delete_all; before_tool_call decides whether that ask becomes an action. The model's plan is a proposal; the hook pipeline is the approval. That separation — the agent proposes, the harness disposes — is what lets you run a capable agent without giving it an unsupervised blank check.

Let's build that pipeline. The lab gives you a tool call, a before_tool_call hook that denies one tool and rewrites another, and a tool_result_persist hook that redacts secrets. Your job is the dispatch function that wires them in the right order: ask before_tool_call first, honor a denial, use the rewritten call if it changed, run the tool, then run the result through tool_result_persist before anything is persisted.

A plugin needs to block any delete_all tool call and strip access tokens out of tool results before they're saved. Which two hooks does it use?

Chapter 6: Timeouts & the Four Terminations

A run that starts must end. The discipline of the agent loop is that it can end in exactly four ways — no more, no fewer — and telling them apart is the difference between an operator who understands their system and one who panics.

Three of the four stop the run. One of them, crucially, does not. That last one is the subtle case the whole chapter is built around.

The four ways a run ends.
  • 1. It completes. The model produced a final answer, the reply streamed out, the transcript was persisted. The normal, happy path. The run is done.
  • 2. The agent timeout fires → ABORT. A run has a hard ceiling on its own runtime (the runtime default is enormous — forty-eight hours — a safety net, not an everyday limit). If a run somehow runs that long, the gateway aborts it. The run is stopped.
  • 3. An external abort signal → CANCEL. Something outside the run — an operator pressing stop, a shutdown — sends an AbortSignal. The run is cancelled and stopped.
  • 4. The agent.wait timeout fires → the WAITER detaches, but THE RUN KEEPS GOING. Remember from Chapter 1: agent.wait blocks the caller for a short window (default thirty seconds) hoping to return the final result. If that window elapses first, the caller gives up waiting — but the run itself is untouched and continues to completion in the background.

Sit with why case 4 is different, because it's the one that trips people. In cases 2 and 3, something stopped the run: the agent timeout and the AbortSignal both reach into the running work and halt it. In case 4, nothing stopped the run at all. The thirty-second agent.wait timeout is a property of the waiter, not the run. It says "the caller stopped listening," not "the work stopped happening."

The trap: "my call timed out" does not mean "the run failed." A script calls agent.wait, gets a timeout after thirty seconds, and concludes the agent crashed. Wrong. The agent is still working — reading files, calling tools — and will finish, persist its transcript, and emit its events normally. The caller simply detached too early. The fix is never "abort and retry" (that kills good work); it's "stop blocking, switch to watching the event stream for this runId." Distinguishing a stopped run (2, 3) from a detached waiter (4) is the single most important judgment in this chapter.

So when you classify how a run ended, the priorities are clear: a cancel or an agent timeout genuinely stops the run; a wait-timeout only detaches the waiter and leaves the run still running. Let's encode exactly that logic.

Your agent.wait call returns a timeout after 30 seconds. What is the correct conclusion about the run?

Chapter 7: Stalled, Stuck & Long-Running

Chapter 6 was about how a run ends. This chapter is about a run that hasn't ended and you're staring at it wondering: is something wrong, or is it just slow? An operator needs to answer that without reflexively killing the run — because killing a healthy run that's merely busy throws away real work.

So the gateway gives a run a health classification: a label that describes why a run isn't producing output right now. Three labels cover the cases:

Three kinds of "not finished yet."
  • Stalledno progress. The run isn't advancing: no new tokens, no tool activity, no forward motion. Something has gone wrong; this is the one that usually warrants a look or an abort.
  • Stuckwaiting on something. The run is blocked on an external dependency — a tool that hasn't returned, a node that hasn't answered, a lock it's waiting to acquire. It's not broken; it's parked, waiting for something else to move first.
  • Long-runninglegitimately slow. The run is genuinely working — a big model call, a long tool, many loop iterations. It's making progress; it just takes a while. Killing it would be a mistake.

The whole value of these labels is that they let an operator tell "hung" from "busy." Without them, every run that isn't done looks the same — a spinner that might be dead or might be thinking — and the only tool you have is the kill switch, which is as likely to destroy good work as to fix a problem. With them, you can act correctly: a stalled run probably needs intervention; a stuck run needs you to fix the thing it's waiting on (or accept the wait); a long-running run needs nothing but patience.

Connect this back to Chapter 6 and the picture closes. A long-running run that crosses the (enormous) agent timeout becomes an abort. A stuck run waiting on a tool that never returns will, eventually, hit the same ceiling. But until those limits, the health label is what stops you from confusing slow-and-fine with broken-and-hung — the difference between an operator who trusts their system and one who keeps yanking the plug on work that was about to succeed.

Why this is a diagnostic, not a control. Notice the health classification doesn't do anything to the run — it doesn't abort, retry, or cancel. It only describes. The acting is left to the operator (or to a policy you write on top of it, perhaps as a hook). Good systems separate observing the state of the world from changing it; the stalled/stuck/long-running labels are pure observation, which is exactly why you can trust them.
A run isn't producing output. The gateway labels it stuck rather than stalled. What's the difference, and what should you do?

Chapter 8: Showcase & Connections

Here is a whole run, laid out left to right, that you can scrub through. Drag the slider to move the playhead across the run's life: acceptance, the lock, context assembly, the infer→tool→infer cycle in the middle, the streamed reply, and persistence. The active phase highlights and the caption explains what's happening there. This single timeline is the map of the entire lesson.

Scrub a run, end to end

Drag the playhead to walk the run through its phases: accept (a runId is issued) → lock (the session write lock is acquired) → context (workspace, skills snapshot, files) → the model ↔ tool cycle (the agentic heart, several turns) → stream (the reply flows out token by token) → persist (everything is written to the transcript). Press agent.wait timeout to watch the waiter detach partway through — while the run itself keeps right on going.

playhead0%
Drag the playhead to begin.
The whole run in one breath. An operator calls agent; the gateway validates, resolves the session, and accepts — returning a runId at once (Ch 1). The run takes the per-session write lock so it can't collide with another run on the same transcript (Ch 2). It assembles context — workspace, skills snapshot, files — freezing a stable world (Ch 3). Then the infer→tool→infer cycle runs, capped, until a final answer (Ch 4), with hooks able to deny, rewrite, and redact along the way (Ch 5). The reply streams out and the transcript is persisted. The run ends one of four ways — and a wait-timeout detaches the caller without stopping the run (Ch 6). While it runs, a health label tells busy from hung (Ch 7).

Where each thread continues

Related lessons on this site

The Feynman test. Close the lesson and explain to a friend: the six stages of a run; why acceptance returns a runId immediately and how agent differs from agent.wait; why runs are serialized per session by a write lock; what the infer→tool→infer cycle is and why it needs a cap; what before_tool_call and tool_result_persist can do; the four ways a run ends — and especially why a wait-timeout does not stop the run. If you can, you own the agent loop, and Lessons 5–6 are just the internals filling in.
In one sentence, what IS the agent loop?