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

The Command QueueSteering a Running Agent

You message your agent three times while it's mid-task. What should happen — interrupt it, queue them, fold them in, or merge them? OpenClaw's command queue answers this with lanes, concurrency caps, and four inbound modes. You'll build the lane-aware scheduler, the four modes, and the drop policies that keep it from drowning.

Prerequisites: Lessons 1–5 (especially Lesson 4's per-session serialization). You know what FIFO and concurrent mean. A little Python for the labs.
9
Chapters
2
Simulations
3
Code Labs

Chapter 0: Why — Two Runs in One Conversation Collide

The whole chapter in one sentence. When two agent runs touch the same conversation at the same time, they overwrite each other's transcript and corrupt the shared memory — so the gateway needs a piece of software that decides what happens to every message that arrives while the agent is still busy. That piece is the command queue.

By Lesson 5 your gateway can run an agent: a message comes in, it builds a prompt, calls the model, runs tools, streams a reply back. Nice and tidy — when there is exactly one message at a time. But people don't message one at a time. You ask your assistant to "summarize that long email," and while it's reading the email you fire off "actually, keep it under three sentences," and then "and make it friendly." Three messages, one conversation, and the agent is still working on the first.

Watch what goes wrong if you let each message just start its own run. Run one reads the conversation, thinks, and begins writing back: "Here is your summary…" Meanwhile run two also started — it read the same conversation a moment later, is also writing, and appends its own "Here is your summary…" The two runs interleave. Both are writing to the one shared transcript — the list of "user said… / assistant said…" turns that is the conversation's memory — and their words braid together into nonsense. Worse, run two never saw run one's answer, so it can't build on it; it just clobbers it.

The core hazard: a shared, mutable transcript. A conversation is a single ordered log that both the user and the agent append to. Two runs writing to it at once is the classic race condition — two writers, one resource, no rule about who goes first. The fix is the oldest one in the book: serialize writes to that resource. Within one conversation, only one run at a time.

But "only one run at a time" is too blunt if you take it globally. You might be chatting with the agent on your phone and have a scheduled job running in the background and a teammate talking to it in a different channel. Those are different conversations — different transcripts — so there is no race between them. Forcing them to wait in one global line would make the whole system feel sluggish for no reason.

So the queue's job has three parts, and the whole rest of this lesson is just filling them in. Serialize within a conversation (one run at a time per conversation, so transcripts never collide). Parallelize across conversations (different conversations run side by side). And decide what to do with a message that arrives mid-run — does it wait, fold into the running agent, merge with its neighbors, or stop the agent cold? Get those three right and three rapid-fire messages become a feature, not a corruption bug.

Why is letting each incoming message immediately start its own agent run a bug when several arrive in the same conversation?

Chapter 1: Lanes & FIFO — One Active Run per Conversation

The queue's first job — serialize within a conversation, parallelize across — has a clean shape: a lane-aware FIFO queue. Let's unpack that two words at a time.

FIFO means first in, first out: jobs come out in the order they went in, like a single-file line at a counter. Lane-aware means there isn't one global line — there are many lines, one per conversation, and a job is filed into a lane by its session key. The session key is just the identity of a conversation: roughly "this channel, this chat." Two messages from the same chat share a session key, so they land in the same lane; messages from different chats get different keys and different lanes.

The invariant the lanes enforce. Within a single lane (one session key), at most one run is active at a time. The rest of that lane's jobs wait their turn in arrival order. Across lanes, runs proceed in parallel. That's the whole serialize-within / parallelize-across promise, expressed as a data structure. Each lane is its own tiny FIFO; the lanes don't block each other.

Now, "at most one per lane" is the default, but it isn't the only setting. Each lane carries a concurrency cap — the maximum number of runs allowed active in that lane at once. OpenClaw's defaults are deliberate: an unconfigured lane caps at 1 (the strict serialize-everything default), the main lane — your foreground chat — caps at 4, and the subagent lane (spawned helper agents) caps at 8. The numbers grow as the work gets more independent: foreground turns within a chat are fairly sequential, but eight subagents doing eight unrelated sub-tasks can safely fly in parallel.

Sitting above all of this is one more dial: a global cap, agents.defaults.maxConcurrent, that bounds the total number of runs active across every lane at once. Per-lane caps protect each conversation; the global cap protects the machine — it stops a thundering herd of conversations from all firing model calls at the same instant and melting your rate limits or your CPU.

Don't leave the human hanging. A message can sit in a lane waiting for the run ahead of it to finish — that's correct, but it would feel broken if the user saw nothing. So the moment a message is accepted into a lane, the gateway fires a typing indicator back to that surface. "I've got it, I'm working on it" arrives instantly even though the actual run starts a beat later. Acknowledge now, schedule fairly, run when the lane is free.

Let's make the invariant concrete by building the scheduler itself: file jobs into lanes by session key, and prove that within a lane runs never overlap while across lanes they happily do.

Two lanes: serialize within, parallelize across

Two conversations, A and B, shown as horizontal tracks. Messages arrive as tokens and turn into runs (filled bars). Inside a lane only ONE bar is active at a time; the rest wait in arrival order. Across lanes the bars happily overlap. Drag the arrival rate up and watch each lane back up while the other keeps flowing.

arrivals / sec1.0
Streaming arrivals into two lanes…
The subagent lane caps concurrency at 8 while an unconfigured lane caps at 1. What is the reasoning behind the higher cap?

Chapter 2: The Four Modes — What to Do Mid-Run

The lane scheduler handles order. But it leaves the most interesting question open: a message arrives while the agent is mid-run on that same conversation — what should happen to it? There isn't one right answer; it depends on what you meant. So OpenClaw exposes four inbound modes, and the queue applies whichever one is active.

The four modes.
  • steerinject the message into the running agent. Don't wait, don't restart; thread the new instruction into the run already in flight so it can adjust course. This is the default.
  • followupqueue it for the next turn. Let the current run finish untouched, then handle this message as the next job in the lane.
  • collectcoalesce a burst into one. Hold messages briefly; if more keep arriving, keep holding; once things go quiet, merge everything that piled up into a single followup.
  • interruptabort and start fresh. Stop the current run and begin a new one on the newest message. Use when the new message makes the old run pointless.

Map them to the email example. You said "summarize that email," then "keep it under three sentences." With steer, the second message slips into the running agent and it shortens the summary it's already writing — one smooth answer. With followup, it finishes the long summary first, then does a second turn applying the constraint — two answers, both honored. With collect, if you'd typed all three lines in a flurry, the queue waits for you to stop, then treats "summarize / under three sentences / and friendly" as one combined request. With interrupt, if you'd instead said "never mind, what's the weather?", the email run is pointless — abort it and answer the weather.

Why is steer the default? Because it matches the most common human intent: while the agent works, you usually want to refine, not restart or stack up replies. Steering feels like talking to a person who is already on the task — "oh, and make it shorter" lands while they're still writing. The other modes are there for the cases steer doesn't fit. Let's implement the decision itself: given a mode and the current run state, what action does the queue take?

You're chatting and the agent is mid-reply when you add "oh, and keep it under three sentences." Under the default mode, what happens?

Chapter 3: Steering in Depth — The Tool Seam

"Inject the message into the running agent" sounds magical. It isn't — and the un-magical detail is exactly what makes it safe. Steering does not reach into the agent and yank it mid-thought. In particular, it does not abort in-flight tool calls. If the agent is halfway through reading a file or calling an API, that work runs to completion. Killing a tool call in the middle could leave a half-written file or a dangling request — steering never risks that.

Instead, steering waits for a natural seam. Recall the agent loop from Lesson 4: the agent calls the model, the model asks for some tools, the tools run, their results come back, and then the model is called again with those results. The injected message is delivered after the current batch of tool calls completes and before the next model call begins. That gap — tools done, next think not yet started — is the seam. The new instruction rides in with the tool results, so the very next thing the model sees includes your steer.

Why the seam is the right place. At the seam the agent is in a clean, consistent state: no tool is half-run, no model call is half-streamed. Slipping a message in there is like handing a chef a new note between dishes, not while their hands are in the pan. The agent picks it up on its next decision, naturally, with no corruption and no wasted work.

What you actually see depends on streaming, which we met in Lesson 5. With partial/block streaming on — where the gateway emits the reply in chunks as it forms — steering produces visible, sequential replies: you watch the agent's answer bend toward your new instruction in near real time. It feels like a conversation, because it is one.

Without streaming, the runtime sometimes can't accept a steer in the same turn — there's no live seam to slip into because the whole turn is computed in one shot. For that case there is a fallback: when same-turn steering isn't possible, the queue gracefully degrades, handling the message on the next turn instead (effectively a followup) rather than failing or losing it. Steer when you can; fall back cleanly when you can't.

The subtle promise. Steering is "fold this in at the next safe moment," not "interrupt right now." That single design choice — deliver at the tool seam, never abort a tool — is what lets the default mode be aggressive about responsiveness without ever corrupting a run. Responsive and safe, because it respects the agent's natural rhythm.
A steered message arrives while the agent is in the middle of a tool call. When is it actually delivered into the run?

Chapter 4: Debounce & Coalescing — Taming the Flurry

Here's a failure mode the first three modes don't solve. You're typing a multi-line thought and you hit Enter after each line out of habit: "summarize the email" … "actually under three sentences" … "and make it friendly." Three messages in two seconds. Under followup that's three separate runs stacked up; under steer it's three injections into a run that barely started. Either way you've spent three times the work on what was really one request you happened to type in pieces.

The collect mode fixes this with an idea borrowed straight from electronics and UI engineering: debounce. A debounced action doesn't fire on every event; it waits for a quiet window — a stretch of time with no new events — and only then acts on everything that piled up. A bouncing switch settles; a flurry of keystrokes settles; then you respond once.

How collect works. When a message arrives in collect mode, the queue starts (or restarts) a timer of length debounceMs — default 500 milliseconds. Every new message that lands before the timer expires resets it and is added to a buffer. Only when the window stays quiet for the full debounce does the queue coalesce the whole buffer into one followup and run it. Three quick lines become a single combined request. A flurry never spawns five runs.

The duration is configurable, and OpenClaw accepts friendly units: you can write the window in milliseconds, seconds, minutes, hours, or days (the unit suffixes are ms, s, m, h, d). A chat assistant might use half a second; a digest job that batches notifications might use minutes or hours. Same mechanism, wildly different time scales — the debounce window is just "how long is quiet enough to assume the human is done."

Collect is the mode that turns the queue from a faithful order-keeper into something that understands human typing rhythm. It trades a tiny bit of latency — you wait out the quiet window — for a big win in coherence and cost: one thoughtful answer to your whole thought, instead of three half-answers racing each other.

In collect mode with a 500 ms debounce, you send three lines within one second. How many agent runs result?

Chapter 5: Backpressure — Caps and Drop Policy

Modes decide what to do with a message that arrives during a run. But there's a darker question underneath: what if messages arrive faster than the agent can ever drain them? A lane that just keeps appending will grow without bound — an unbounded queue that quietly eats memory until the process falls over. This is the classic backpressure problem: when the producer outpaces the consumer, you need a rule for pushing back.

OpenClaw's first line of defense is a per-session cap on how many pending messages a lane may hold — default 20. The cap is the promise that a single runaway conversation can never consume unbounded memory. But a cap alone only says "no more than this"; it doesn't say which messages survive when you're full. That's the drop policy.

Three drop policies. When the lane is at cap and a new message arrives:
  • summarize (default) — drop the oldest message to make room, but leave behind one compact summary noting how many were dropped, injected as a synthetic prompt. The agent still learns "you also said a bunch of earlier things, here's the gist," so nothing vanishes without a trace.
  • old — drop the oldest, no trace. Keep the most recent messages; the dropped ones are simply gone. Simplest, but the agent never knows it lost context.
  • new — reject the newest arrival. Keep what you already have; the message that just came in bounces. Useful when the earliest messages are the ones that matter most.

Why is summarize the default? Because it's the least surprising. With old, the agent silently forgets things you said — it might answer as if you never mentioned a constraint. With new, your latest message just disappears, which feels broken. Summarize keeps the queue bounded and preserves a breadcrumb of what was shed, so the agent's view degrades gracefully instead of going silently wrong. Bounded memory, minimal surprise.

Let's build the three policies and prove the property that matters: every one of them keeps the queue at or under the cap, while the naive "just append" version blows past it.

A lane hits its 20-message cap and more messages keep arriving. Why is summarize the default drop policy rather than old?

Chapter 6: Precedence & the /queue Overrides

We now have four modes plus a cap and three drop policies. But who chooses? You might want steer everywhere by default, followup in one noisy channel, and collect for a single burst-prone conversation. OpenClaw resolves this with a clear precedence order: when several settings could apply, the most specific one wins.

Mode resolution, most specific first. The queue checks these in order and uses the first that is set:
  • 1. A per-session /queue override — you typed a command in this conversation. Most specific; always wins.
  • 2. A per-channel settingmessages.queue.byChannel for this conversation's channel. "In Slack, always followup."
  • 3. The global modemessages.queue.mode. The site-wide default you configured.
  • 4. The built-in defaultsteer. What you get if nothing above is set.

This is the same precedence logic you've seen in CSS, in config files, in shell environments: a cascade from broad defaults down to narrow overrides, with the narrowest winning. It means you can set one sensible global mode and then carve out exceptions per channel or per conversation without touching the global setting. Specificity is authority.

The per-session override is driven by a small command language. In a conversation you can type:

commands
# set this conversation's mode
/queue steer
/queue collect

# set mode plus its parameters in one line
/queue collect debounce:0.5s cap:25 drop:summarize

# clear the per-session override (fall back to channel/global/default)
/queue default
/queue reset

So /queue collect debounce:0.5s cap:25 drop:summarize says, for this conversation only: coalesce bursts, with a half-second quiet window, hold up to 25 messages, and summarize on overflow. And /queue reset tears that override back down so the conversation falls through to the channel setting, then the global mode, then the built-in steer. The cascade is always there; the override just sits on top.

Why precedence beats a single global switch. Conversations differ. A focused one-on-one chat wants steer's responsiveness; a firehose group channel wants collect or followup so it isn't shredded into a hundred runs. A flat global setting would force one compromise on all of them. The cascade lets the right mode live at the right scope — broad where you can, specific where you must.
The global mode is steer, the Slack channel is configured to followup, and in one Slack thread you typed /queue collect. What mode does that thread use?

Chapter 7: Lanes for Background Work

We've talked about the main lane — your foreground chat — and the subagent lane. But the gateway also runs work that no human is watching in real time, and that work needs its own lanes so it can never starve the conversation you're actively having.

Picture the alternative without lanes: a scheduled job wakes up to run your nightly digest at the exact moment you message the agent. If they shared one line, your message would wait behind a long batch job — the system would feel frozen precisely when you're using it. Lanes prevent that by giving background work separate tracks that proceed in parallel with foreground chat.

The background lanes. Beyond main and subagent, OpenClaw carries lanes for scheduled and spawned work:
  • cron — scheduled jobs that fire on a timer (the nightly digest, the hourly check).
  • cron-nested — work a cron job itself spawns, kept separate so a scheduled task's children don't crowd the top-level cron lane.
  • nested — sub-work spawned by a run that isn't a subagent proper — nested calls within a conversation's processing.
  • subagent — helper agents a run spins up to tackle sub-tasks (the high-cap lane from Chapter 1).

The point of naming them is isolation: foreground chat lives in main, scheduled work lives in cron, spawned work lives in nested/subagent — and because each lane has its own concurrency budget, a burst of background activity can fill its own lanes without ever blocking the lane your live conversation is in. Background work can't starve foreground chat, by construction.

No external queue, just promises. A striking implementation detail: all of this — lanes, caps, modes, debounce, drop policies — is plain TypeScript with promises. There is no Redis, no RabbitMQ, no external message broker. The "queue" is in-process data structures and the language's own async machinery. For a single-host gateway that owns its connections (Lesson 1), an in-process queue is simpler, has no network hop, and no extra service to keep alive. The right amount of infrastructure is sometimes none.
Why does the gateway give scheduled cron jobs their own lane separate from the main conversation lane?

Chapter 8: Showcase & Connections

Here is the whole queue in motion on one conversation. An agent is running on session A with a progress bar. Press Inject message while it runs and watch the active mode decide what happens: steer folds it in at the next tool seam, followup queues it for after the run, collect waits out a debounce timer then merges, interrupt aborts and restarts on the newest. Flip to a tiny cap and flood it to see the drop policy shed messages. This one widget is everything from Chapters 1–7.

The live command queue — modes, debounce, cap & drop

The bar is the running agent on session A; the small ticks are its tool seams. Pick a mode, press Inject message a few times, and watch the queue react. Lower the cap and inject faster than it drains to watch the chosen drop policy shed messages. The readout names every action as it happens.

mode
cap6
drop
Agent running on session A. Pick a mode and inject a message.
The queue in one breath. Jobs are filed into lanes by session key; within a lane runs are serialized (FIFO, concurrency cap), across lanes they're parallel (Ch 1). A message arriving mid-run is handled by one of four modes — steer / followup / collect / interrupt, default steer (Ch 2). Steering delivers at the tool seam, never aborting a tool (Ch 3). Collect debounces a flurry into one followup (Ch 4). A per-session cap plus a drop policy (summarize / old / new) bounds memory (Ch 5). The mode is chosen by a precedence cascade — session override → channel → global → built-in steer (Ch 6). And background lanes (cron, nested, subagent) keep scheduled and spawned work from starving foreground chat (Ch 7).

Where each thread continues

Related lessons on this site

The Feynman test. Close the lesson and explain to a friend: why two runs in one conversation corrupt the transcript and how lanes fix it; the four modes and one situation where each is the right call; why steering waits for the tool seam instead of aborting; what debounce does to a flurry; the three drop policies and why summarize is the safe default; and the precedence order that picks the mode. If you can, you own how a real agent gets steered while it runs.
In one sentence, what IS the command queue?