Orkas Orkas
Home Blog Architecture
Architecture

Multi-Agent Orchestration in Practice: How Orkas Runs a Lead Agent and Its Sub-Agents

Inside Orkas's multi-agent orchestration: a lead agent turns one request into a plan, dispatches sub-agents by dependency, passes context between steps, and heals from failure.

The previous article was about making one agent reliable: the run loop, tool routing, context compaction, crash-safe sessions. That layer answers "how does a single agent get through a task without falling over?" This article is about the layer above it — what happens when one agent isn't enough, and a job has to be split across a team.

This is what people usually mean by multi-agent orchestration: a lead agent that owns the conversation breaks a request into pieces, hands each piece to a specialized sub-agent, waits for the right ones to finish before starting the next, passes results forward, and keeps the whole thing on the rails when a step fails. Orkas runs this entirely on the user's own machine. Below is how that orchestration layer is built — code scrubbed and generalized, but the structure is real.

The short version A lead agent and its sub-agents, in one workspace This is how Orkas dispatches work today: Commander recruits specialists, runs them in parallel or in series, and you watch it happen.
Download Orkas — free

Lead agent and sub-agents

The mental model is a small team with a clear chain of command. A lead agent (we call it the commander) owns the conversation and the overall context. It doesn't do all the work itself; its job is to decide what needs doing, in what order, and by whom. The sub-agents are specialists — each configured with its own system prompt, its own allowed tools, and its own set of skills. A sub-agent is good at one slice of the work and is invoked when that slice comes up.

Two things make this more than a buzzword. First, sub-agents and skills are first-class units, not prompt tricks: a sub-agent is a real, separately-configured agent, and dispatching to one is a real handoff with its own context. Second, the coordination isn't left to the model's good intentions — it's driven by an explicit artifact that the system, not the model, keeps honest. That artifact is the plan.

A plan is a graph, not a script

When the lead agent decides a request needs more than one step, it writes a plan. The plan is not free-form prose and it is not a linear checklist — it's a small dependency graph (a DAG). Each node is a step, and a step carries everything the orchestrator needs to dispatch it:

interface PlanStep {
  index: number;            // 1-based, stable, never renumbered
  title: string;            // human-readable, shown in the UI
  assignee: string;         // who runs it: "user" | "commander" | a sub-agent
  input?: string;           // the dispatch payload — a template (see below)
  wait_for?: number[];      // upstream step indexes; defaults to [index - 1]
  on_failure?: "abort_plan" | "continue" | "ask_commander";

  // --- runtime state, owned by the orchestrator, NOT written by the model ---
  status: "pending" | "in_progress" | "done" | "failed" | "skipped" | "blocked";
  output_summary?: string;  // short summary of what the step produced
  output_files?: string[];  // files the step produced
  failure_reason?: string;
}

The wait_for field is what turns a list into a graph. By default a step waits for the one before it (a simple chain), but a step can declare it depends on several earlier steps — "summarize" might wait for both "research the market" and "survey competitors." That's a diamond, not a line, and the orchestrator treats it as one.

Who owns the truth: the orchestrator, not the model

Look again at the split in that struct. The model fills in the intent — titles, assignees, inputs, dependencies — when it first writes the plan. But everything about execution statestatus, output_summary, failure_reason — is owned exclusively by the orchestrator. The model proposes the plan once; it never gets to mark its own steps "done."

This separation is deliberate, and it's the single most important decision in the whole design. A language model is perfectly capable of cheerfully announcing "step 3 complete" when step 3 errored, or of losing track of which steps are still outstanding halfway through a long conversation. If state lived in the model's head, the plan would drift from reality. By making state a structured artifact that only the executor writes — and writes only in response to a step actually finishing — the plan stays an accurate mirror of what has really happened. The model decides the shape of the work; the runtime decides what is true about its progress.

Dispatching the steps that are ready

Once a plan exists, a small engine — the executor — drives it forward. The core operation is "find the steps that are ready and dispatch them." A step is ready when it is still pending and every step it waits for has reached a terminal, successful-enough state:

function findReadySteps(plan): PlanStep[] {
  return plan.steps.filter((s) => {
    if (s.status !== "pending") return false;
    const deps = s.wait_for ?? (s.index > 1 ? [s.index - 1] : []);
    return deps.every((d) => isTerminal(plan.step(d).status)); // done or skipped
  });
}

This runs not on a timer but in response to events. Every time a step finishes — a sub-agent returns, the commander wraps up a synthesis turn — the executor reconciles: it records the outcome of the step that just finished, then re-scans for whatever became ready as a result, and dispatches it. Orchestration is this reconcile loop, turning over and over until no steps remain.

The dispatch goes through the same chat, not a side channel

Here's a choice that keeps the system honest: dispatching a step doesn't use a hidden RPC channel. It posts a message into the very same group conversation the user is watching, from the lead agent, @-mentioning the sub-agent. To the sub-agent, being dispatched is indistinguishable from being addressed in chat — it just runs its normal turn. There's no second execution path to keep in sync with the first.

An assignee can be one of three kinds, and each dispatches a little differently:

  • A sub-agent — the common case. The executor resolves the agent's name to its id and posts @<agent> <rendered input> from the commander. The sub-agent picks it up and runs a full agent turn.
  • The user — when a step genuinely needs human input, the step turns into a form and pauses the plan (more on this below). Nothing downstream proceeds until the user answers.
  • The commander itself — for synthesis or decision steps ("read everything above and write the summary"). This is a private wake-up that doesn't post a redundant user-visible message; the lead agent simply takes a turn with the gathered context in hand.

Passing context from one step to the next

A team is only useful if work flows between its members. The mechanism is the input template. When the lead agent writes a step, the input isn't a frozen string — it can reference earlier results, and the executor renders those references at dispatch time:

// step 3.input, as written by the lead agent
"Using the findings below, draft the launch note.\n\n{{step_1.output_summary}}"

// what the sub-agent actually receives at dispatch time
"Using the findings below, draft the launch note.\n\n- Market is growing ~20% YoY; two incumbents…"

Note what gets passed forward: output_summary, a short summary of each finished step — not its entire transcript. This is a budget decision, and it's the same instinct as the harness's context compaction. If every downstream step inherited the full token-by-token history of everything before it, context would balloon and cost would explode after just a few hops. Summaries keep each handoff cheap and keep each sub-agent focused on what it actually needs from upstream, rather than wading through how upstream got there. The initial user message and any attachments are carried along too, so a step three hops down the chain still knows the original ask.

Serial by default — and why

You might expect that when several steps come back ready at once — two branches of a diamond, say — the orchestrator fires them all in parallel. It could; instead, today it dispatches one ready step at a time, the earliest by index, and lets the rest wait for the next reconcile. Running a whole team strictly one-at-a-time is a deliberate, conservative choice, and it's worth being honest about why.

The reason is correctness under concurrency. Picture two sub-agents finishing at nearly the same instant. Both completions trigger a reconcile; both reconciles read the plan; both see the same downstream step still sitting at pending — and both dispatch it. Now the same step runs twice. To make that impossible, every read-modify-dispatch cycle on a given conversation is serialized behind a per-conversation lock:

// all state-changing paths for one conversation run under one mutex
planLock(uid, cid).runExclusive(async () => {
  const plan = await readPlan(uid, cid);
  applyOutcomeOfFinishedStep(plan);   // mark done / failed / skipped
  await dispatchReady(plan);          // dispatch the next ready step
});

The lock guarantees that "record what finished" and "decide what's next" happen as one indivisible unit, so a downstream step can never be dispatched twice. With that lock in place, dispatching steps one at a time is the simplest thing that is obviously correct. Genuine parallel fan-out is a tractable extension on top of this foundation — but the foundation is a serialized, race-free executor, and that ordering of priorities (correct first, fast later) is the point.

When a step goes wrong

Running on a real machine against external model APIs, failure is routine, and the orchestrator sorts it into a few cases instead of treating every error the same.

First it asks whether the failure was merely transient — a dropped connection, a rate limit, a blip. If so, and the step hasn't already burned through a small retry budget, the step is quietly rolled back to pending so the next reconcile re-dispatches it. (This sits above the harness's own in-run retries; the plan only re-dispatches after the agent's own attempts are exhausted, with a hard cap so a truly broken step can't loop forever.)

If the failure is real, the step's declared on_failure policy decides what the team does next:

  • abort_plan — this step mattered enough that nothing downstream makes sense without it. Mark it failed, and cascade: every still-pending step is marked skipped. The plan stops cleanly rather than building on a missing foundation.
  • continue — this step was optional. Mark it skipped and let downstream steps proceed as if it simply produced nothing.
  • ask_commander (the default) — neither blindly abort nor blindly continue. Mark it failed and wake the lead agent to look at what happened and decide — retry differently, route around it, or stop and ask the user.

There's a sixth status worth calling out: blocked. A sub-agent partway through its step may realize it needs something only the user can provide, and surface a form or a question. The step doesn't fail — it goes to blocked, and the whole plan pauses. The moment the user answers, the executor reconciles and the team picks up exactly where it left off. A blocked plan is a paused plan, not a broken one.

Every step is a full agent run

It's worth closing the loop back to the previous article. When the orchestrator dispatches a step to a sub-agent, that sub-agent doesn't run some stripped-down routine — it runs the full harness loop: its own streaming run loop, its own tool calls, its own context window with compaction, its own crash-safe session. Orchestration sits cleanly on top of the single-agent runtime; it never reaches inside it. The lead agent decides the shape of the work and the order of the handoffs; each sub-agent, once handed its slice, is a complete agent in its own right.

That layering is why the two articles compose. The harness makes one agent trustworthy for one task. The orchestrator composes several trustworthy agents into a team that can take on a task too big, or too varied, for any one of them.

A few decisions that mattered

The orchestrator owns execution state, the model owns intent. The model proposes the plan; only the runtime marks steps done, failed, or skipped, and only in response to something actually happening. This one boundary is what keeps the plan an honest mirror of reality instead of the model's optimistic guess.

Dispatch through the same conversation, not a side channel. A dispatched step is just a message from the lead agent to a sub-agent. One execution path, nothing hidden to drift out of sync, and the user can watch the team work in the same thread they're already reading.

Summaries, not transcripts, between steps. Each handoff carries a short summary of upstream output. It keeps context budgets sane across long chains and keeps each sub-agent focused on what it needs, not on how the previous one got there.

Correct before parallel. A per-conversation lock serializes every read-modify-dispatch cycle, and steps dispatch one at a time. Strictly serial is the version that is obviously free of double-dispatch races; parallel fan-out is an optimization to layer on a foundation that's already correct.

Wrapping up

Orkas's orchestration layer has no exotic algorithm at its heart. Its value is in a few boundaries held firmly: a plan that is a dependency graph rather than a script; execution state owned by the runtime rather than the model; dispatch that flows through the same chat the user is watching; context that moves between steps as summaries; and an executor that puts correctness ahead of concurrency. Each is simple on its own. Together they turn a single reliable agent into a team that divides labor, passes work forward, and recovers when a piece of it goes wrong.

If you want the layer underneath this one, read how a single agent is engineered to run reliably. If you want the layer that makes each agent get better with use, read how Orkas agents learn from their own work. And if you would rather direct this layer than build it, Orkas ships it as open-source AI agent orchestration that runs on your own machine.