Swarm: Orchestrate Concurrent Sub-Agents in Code Mode

Learn how to coordinate numerous sub-agents from Code Mode scripts with structured results, bounded fan-out, and live progress. This page is for developers using the experimental Swarm feature.

Read this when

  • You want a Code Mode script to fan out work across several agents
  • You need structured child results, decision gates, or first-completion pipelines
  • You are enabling or tuning tools.swarm limits
  • You want to observe collector children in chat

Swarm is an experimental, opt-in method for coordinating numerous sub-agents from a Code Mode script. Standard JavaScript or TypeScript control structures like Promise.all, while, and if let you distribute tasks, gather outputs, and make choices.

No graph DSL or separate workflow format exists. The script itself acts as the orchestrator. Swarm introduces awaitable collector children, organized results, limited concurrency, and progress updates into that script.

Enable Swarm

The suggested approach is Settings → Labs → Swarm within the Control UI. This toggle applies right away and records tools.swarm.enabled into your configuration.

Alternatively, you can turn on Swarm directly inside openclaw.json:

{
  tools: {
    swarm: {
      enabled: true,
      maxConcurrent: 8,
      maxChildrenPerGroup: 50,
      maxTotalPerGroup: 200,
      waitTimeoutSecondsMax: 600,
      defaultAgentId: "",
    },
  },
}

Boolean shorthand activates or deactivates the feature while keeping every other value at its default:

{
  tools: {
    swarm: true,
  },
}
FieldDefaultDescription
enabledfalseMakes collector-mode spawn options, agents_wait, and the Code Mode agents.* guest API available.
maxConcurrent8Highest number of collector children executing at once within a swarm group. Additional accepted children wait in FIFO order.
maxChildrenPerGroup50Highest number of live collector children allowed in one group.
maxTotalPerGroup200Maximum collector children a group can spawn during its entire lifetime. This acts as the safeguard against runaway spawning.
waitTimeoutSecondsMax600Largest timeout that a single agents_wait call will accept. The default for that call is 30 seconds.
defaultAgentId""The target agent used when a spawn leaves out agentId. An empty value uses the requesting agent. Any existing sub-agent allowlists still apply.

Numeric values must be positive integers. OpenClaw restricts maxConcurrent to 1, 1000, maxChildrenPerGroup to 1, 10000, maxTotalPerGroup to 1, 100000, and waitTimeoutSecondsMax to 1, 86400.

You can override Swarm for a single configured agent with agents.entries.*.tools.swarm. The per-agent object layers on top of the top-level tools.swarm object.

Requirements

The agents.run, phase, and log guest globals demand both Swarm and OpenClaw Code Mode to be active:

{
  tools: {
    codeMode: true,
    swarm: true,
  },
}

Code Mode also needs effective access to sessions_spawn. Tool profiles, allow/deny policy, provider rules, and sandbox policy can strip away that tool. Refer to Code Mode activation and Sub-agents if a script indicates that sessions_spawn is not accessible.

defaultAgentId and per-run agentId values must reference a configured target that the requester's subagents.allowAgents policy permits. OpenClaw rejects an unknown or disallowed target rather than defaulting to a different agent.

Write a Swarm script

With Swarm enabled, Code Mode offers this guest API:

type AgentRunOptions = {
  label?: string;
  model?: string;
  thinking?: string;
  fastMode?: boolean | "auto";
  agentId?: string;
  schema?: Record<string, unknown>;
  phase?: string;
};

agents.run(prompt: string, options?: AgentRunOptions & { schema?: undefined }): Promise<string>;
agents.run<T>(prompt: string, options: AgentRunOptions & { schema: Record<string, unknown> }): Promise<T>;
phase(title: string): void;
log(message: string): void;

In the absence of schema, agents.run() returns the child's final text. When a JSON Schema is supplied, it returns the value sent through the child's structured_output tool. A child that fails, gets killed, times out, or produces invalid schema output causes the promise to reject with a SwarmAgentError. Review the precise generated declarations and brief orchestration patterns from API.read("agents.d.ts") inside Code Mode.

Use label when you want a recognizable child name to appear in the dashboard and sidebar. To publish a phase right before that child begins, pass phase through the options, or invoke phase() when multiple children share the same stage. A short progress note gets published via log(). These progress calls operate in a fire-and-forget manner; if the UI happens to be unavailable, the script is never held up.

Fan out in parallel with structured results

In this example, one researcher gets launched per topic, the script waits for all of them to finish, and then a final child is asked to synthesize their structured reports:

const reportSchema = {
  type: "object",
  properties: {
    finding: { type: "string" },
    evidence: { type: "array", items: { type: "string" } },
    confidence: { type: "number" },
  },
  required: ["finding", "evidence", "confidence"],
  additionalProperties: false,
};

const topics = ["authentication", "storage", "recovery"];
phase("Independent review");

const reports = await Promise.all(
  topics.map((topic) =>
    agents.run(`Review the ${topic} path. Return one finding with evidence.`, {
      label: `review-${topic}`,
      thinking: "high",
      fastMode: "auto",
      schema: reportSchema,
    }),
  ),
);

phase("Synthesis");
log(`Collected ${reports.length} independent reports.`);

return await agents.run(
  `Reconcile these reports and explain disagreements:\n${JSON.stringify(reports)}`,
  { label: "synthesis" },
);

The fan-out and fan-in boundary is defined by Promise.all. OpenClaw spins up as many as maxConcurrent children for the group, with the remainder queued in submission order.

Separately, Code Mode caps concurrent guest bridge calls using tools.codeMode.maxPendingToolCalls, where the default is 16 and the ceiling is 128. For groups that are especially large, launch bounded batches that stay under that limit, leaving room for phase(), log(), and child wait transitions. Note that maxConcurrent only restricts how many children run at once; it does nothing to raise the guest bridge-call ceiling.

Loop on a decision gate

When each iteration must determine whether another one is required, use a bounded while loop:

const gateSchema = {
  type: "object",
  properties: {
    ready: { type: "boolean" },
    reason: { type: "string" },
    nextAction: { type: "string" },
  },
  required: ["ready", "reason", "nextAction"],
  additionalProperties: false,
};

let pass = 0;
let decision = { ready: false, reason: "Not checked", nextAction: "Review" };

while (!decision.ready && pass < 4) {
  pass += 1;
  phase(`Decision pass ${pass}`);
  decision = await agents.run(
    `Check whether the release evidence is complete. Previous decision: ${JSON.stringify(decision)}`,
    {
      label: `release-gate-${pass}`,
      schema: gateSchema,
    },
  );
  log(decision.reason);
}

if (!decision.ready) {
  throw new Error(`Gate still closed after ${pass} passes: ${decision.nextAction}`);
}

return decision;

Decision loops always need bounds. maxTotalPerGroup acts as the final safety net, not as a replacement for a well-defined termination condition.

Process the first child that finishes

Because agents.run() resolves to an ordinary promise, Promise.race can respond to the first Code Mode child that finishes. For harnesses relying on the lower-level tools, agents_wait offers the same first-completion boundary: it resolves the moment at least one requested run finishes, or when the bounded timeout runs out. The full drain loop is covered in Use Swarm from other harnesses.

How collector children behave

Collector children are ordinary isolated sub-agent sessions, though they follow a different completion path. Instead of announcing or steering a reply back into the parent session, they write a durable collector result that the parent can await.

The target agent gets resolved in this sequence:

  1. agentId on the spawn or agents.run() call.
  2. tools.swarm.defaultAgentId.
  3. The requesting agent.

A dedicated, lean worker agent proves handy when swarm children need a smaller tool surface, a cheaper model, or a stricter sandbox policy. OpenClaw does not come with a built-in worker agent id; you have to configure one before designating it as the default. Harden that worker by adding tools.swarm: false to its per-agent configuration, which lets it be spawned while preventing it from starting swarms from its own top-level sessions:

{
  tools: { swarm: { enabled: true, defaultAgentId: "worker" } },
  agents: {
    list: [
      {
        id: "main",
        default: true,
        subagents: { allowAgents: ["worker"] },
      },
      { id: "worker", tools: { swarm: false } },
    ],
  },
}

Collector approvals fail closed. An operator approval prompt is never opened by a child. Any tool action that would normally require approval gets denied, and the child can surface that denial in its result so the script can decide on the next step.

For structured output, OpenClaw injects a synthetic structured_output tool into the child and checks its payload against the supplied JSON Schema. A payload that is invalid or missing triggers one corrective nudge. If the retry still fails validation, the collector completion preserves the child's raw text, leaves structured unset, and includes schemaError. Those fields are exposed by the low-level agents_wait result for explicit recovery logic.

Children are leaves

By default, swarm children are leaves. The universal agents.defaults.subagents.maxSpawnDepth guard stops a child from spawning its own children at the default depth of 1. The standard orchestration pattern is to hand work back to the parent rather than spawning more work from a child:

const plan = await agents.run("Plan this job as independent tasks.", {
  schema: {
    type: "object",
    properties: { tasks: { type: "array", items: { type: "string" } } },
    required: ["tasks"],
    additionalProperties: false,
  },
});
return await Promise.all(plan.tasks.map((task) => agents.run(task)));

Nested sub-agents are an operator opt-in via agents.defaults.subagents.maxSpawnDepth and are discouraged for Swarm. Group caps, budgets, and observability all assume flat collector groups.

Each child has exactly one admission owner. Announce and interactive children rely on agents.defaults.subagents.maxChildrenPerAgent (defaulting to 5) and do not count collector children. Collector children use only maxChildrenPerGroup and maxTotalPerGroup; they never touch the per-session child budget. The spawn depth guard still applies to both modes.

Once admitted, children above maxConcurrent queue FIFO within their swarm group, nested inside the global sub-agent lane. These concurrency layers queue work instead of rejecting it. A collector spawn that goes over either group cap is rejected, with the relevant config key included in the error.

Observe a Swarm

While a swarm is active, keep the parent session open in Chat. A compact Swarm progress widget shows up between the transcript and composer in the Control UI and the native Android, iOS, and macOS chat surfaces, rendering each active collector group as one dot per child with queued, running, done, or failed state. Accessible labels identify each child and its status; the Control UI also surfaces them as dot tooltips. Once every group child hits a terminal state, the widget disappears.

The session sidebar preserves the normal parent/child tree. Expanding the parent row lets you inspect a collector child or open its transcript without losing the swarm hierarchy.

Collector results stay waitable until their group is archived. After every member reaches its retention deadline, OpenClaw archives the group's children as a batch, so completed swarms do not linger in the live session tree.

Use Swarm from other harnesses

Swarm works fine without OpenClaw Code Mode. Its core tools are harness-independent: start collector children with sessions_spawn({ collect: true }) and drain them using bounded agents_wait calls.

Eligible dynamic OpenClaw tools are automatically exposed by Codex Code Mode under tools.*. It does not rely on OpenClaw's QuickJS guest API or demand tools.codeMode, though tools.swarm still has to be enabled. Codex harness agents_wait calls support the full 600-second timeout.

With the currently supported Codex runtime, dynamic OpenClaw tool results arrive in Code Mode as JSON text. Parse each result before reading any fields. Codex also serializes dynamic tool calls, so Promise.all does not submit several sessions_spawn calls at the same time. Launch collectors in a bounded loop; children that are already accepted can keep running while later launches get submitted.

function parseToolResult(value) {
  if (typeof value !== "string") return value;
  return JSON.parse(value);
}

const tasks = [
  "Check the authentication path.",
  "Check the storage path.",
  "Check the recovery path.",
];
const launches = [];

for (const [index, task] of tasks.entries()) {
  const launch = parseToolResult(
    await tools.sessions_spawn({
      task,
      collect: true,
      label: `review-${index + 1}`,
    }),
  );
  if (launch.status !== "accepted") {
    throw new Error(launch.error ?? "Collector spawn was not accepted.");
  }
  launches.push(launch);
}

const pending = new Set(launches.map((launch) => launch.runId));
const completed = [];

while (pending.size > 0) {
  const ids = [...pending].slice(0, 1000);
  const batch = parseToolResult(
    await tools.agents_wait({
      ids,
      timeoutSeconds: 30,
    }),
  );

  // Rotate this bounded window behind ids that have not been checked yet.
  for (const runId of ids) {
    if (pending.delete(runId)) pending.add(runId);
  }

  for (const item of batch.completed) {
    pending.delete(item.runId);
    if (item.status !== "done") {
      throw new Error(item.schemaError ?? item.result ?? `${item.runId}: ${item.status}`);
    }
    completed.push(item); // Process each result as soon as it finishes.
  }

  for (const failure of batch.errors ?? []) {
    pending.delete(failure.runId);
    throw new Error(`${failure.runId}: ${failure.error}`);
  }
}

return completed;

Each agents_wait call accepts 1, 1000 run ids. It returns:

type AgentsWaitResult = {
  completed: Array<{
    runId: string;
    status: "done" | "failed" | "killed" | "timeout";
    result: string;
    structured?: unknown;
    schemaError?: string;
    sessionKey: string;
    label?: string;
    usage?: { inputTokens: number; outputTokens: number };
  }>;
  pending: string[];
  errors?: Array<{
    runId: string;
    error: "not_found" | "not_owner";
  }>;
};

The call returns immediately when any requested child is already complete, when at least one pending child completes, when no valid pending ids remain, or when its timeout expires. Completed records are idempotent, so passing an already-completed run id returns its result again. Only the spawning session or its authorized parent chain can wait on a collector.

This is bounded long polling, not a busy status loop. Keep passing only the remaining run ids until pending is empty. Collector mode supports native OpenClaw sub-agents; it does not support ACP runtime, thread binding, visible sessions, or persistent session mode.

Limits and roadmap

Swarm v1 runs one-shot collector children; the planned agents.session() API will add stateful multi-turn workers. Children currently run on the local Gateway's sub-agent lane; cloud placement is planned as an explicit spawn option. Saved workflow definitions and a graph DSL are not part of Swarm's current direction.

2,264 words · updated Aug 6, 2026