Install
openclaw skills install recursive-spawnEnables an Openclaw agent to spawn sub-agents (child Openclaw instances) when a task is too large, complex, or parallel to handle alone. Use this skill whene...
openclaw skills install recursive-spawnAllows an Openclaw agent to spawn child Openclaw agents, passing them exactly the context they need to carry out their piece of work and report results back.
Helper script: scripts/spawn_openclaw.py — copy this into your project and import from it.
It contains spawn_openclaw(), spawn_openclaw_async(), is_error(), and read_result().
Multi-provider: Uses LiteLLM — pass any supported
model string via the model= argument. Default is "anthropic/claude-opus-4-6".
Requires: litellm Python package (pip install litellm) and the API key env var for
your chosen provider (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY).
Tool format: OpenAI function-call format. LiteLLM translates to each provider's native format automatically.
Credential:
spawn_openclaw.pyuses LiteLLM to call your chosen provider. Set the matching API key env var (e.g.ANTHROPIC_API_KEY,OPENAI_API_KEY). Never put keys in a payload or snapshot.
Tool format: Tools must be in OpenAI function-call format. LiteLLM translates them to each provider's native format automatically.
Filesystem access: Passing
tools=to a child agent grants that child whatever capabilities those tools carry. File-access tools allow children to read and write arbitrary paths. Only supply tools you would trust the parent to use directly. When in doubt, omittools=— the child will return results in its summary text.
Snapshot sanitization:
progress_so_faris sent to the Anthropic API and injected into the child's context. Before spawning, review snapshots and strip any secrets, credentials, personal data, or other sensitive information.
Spawn a child agent when any of these are true:
Do not spawn for trivial one-step tasks; keep it in the current agent.
MAX_DEPTH = 3 (configurable in scripts/spawn_openclaw.py).
| Depth | Role |
|---|---|
| 0 | Root / parent agent |
| 1 | Direct child (default spawn) |
| 2 | Grandchild (only if child's sub_task explicitly permits spawning) |
| 3+ | Blocked — raises ValueError |
Always pass depth=<current_depth + 1> when calling spawn_openclaw() from inside a child.
Every spawn call must include these three fields:
{
"main_task_title": "<short human-readable title of the overall parent task>",
"progress_so_far": "<markdown summary of what has already been done, decisions made, artefacts produced, and anything the sub-agent must know to avoid redoing work>",
"sub_task": "<clear, self-contained description of exactly what this child agent must do, including expected output format and where/how to return results>"
}
| Field | Rules |
|---|---|
main_task_title | ≤ 10 words. Stable across all children of the same parent. |
progress_so_far | Include: steps completed, key decisions, files written, variables/state the child needs. Exclude: raw data the child doesn't need. Keep it dense but readable. |
sub_task | Must be self-contained. Assume the child has zero memory of the parent conversation. Include: what to do, inputs, expected output format, where to put results (file path, return value, etc.). |
Confirm the sub-task is genuinely separable. If in doubt, handle it yourself.
Before spawning, write a progress snapshot. This becomes progress_so_far for the child
and serves as the parent's own checkpoint in case it needs to resume.
Sanitize before spawning.
progress_so_faris sent to the Anthropic API and injected into the child's context. Remove any secrets, API keys, passwords, tokens, or personal data before including them in the snapshot.
## Progress Snapshot — <main_task_title>
**Completed:**
- <step 1>
- <step 2>
**Artifacts produced:**
- <file or output name>: <one-line description>
**Decisions made:**
- <decision>: <rationale>
**Pending (what the child will handle):**
- <sub-task description>
Fill in the three required fields from the snapshot above.
Import from scripts/spawn_openclaw.py:
Important: Without
tools=, the child is a pure language model — it can reason and produce text but cannot read or write files. If the sub_task requires file I/O, pass the appropriate tool definitions (e.g. Anthropic computer-use tools, custom file tools). When tools are omitted, collect the child's result from thesummarystring directly rather than callingread_result().
from spawn_openclaw import spawn_openclaw, is_error, read_result
# my_tools = [...] # OpenAI function-call format tools if the child needs file I/O
payload = {
"main_task_title": "Refactor authentication module",
"progress_so_far": (
"## Progress Snapshot\n"
"**Completed:**\n- Audited existing auth flow\n- Identified 3 outdated JWT helpers\n\n"
"**Artifacts produced:**\n- `/tmp/audit_report.md`: full list of issues\n\n"
"**Decisions made:**\n- Use PyJWT 2.x API; drop legacy HS256 fallback\n\n"
"**Pending (child handles):**\n- Rewrite auth/jwt_helpers.py per audit report"
),
"sub_task": (
"Rewrite `/src/auth/jwt_helpers.py` using PyJWT 2.x. "
"Read `/tmp/audit_report.md` for issues to fix. "
"Write the rewritten file to `/tmp/jwt_helpers_new.py` and a "
"one-paragraph summary to `/tmp/jwt_helpers_changes.md`."
),
}
try:
# Swap model= to use any LiteLLM-supported provider:
# "openai/gpt-4o", "gemini/gemini-2.0-flash", "groq/llama-3.3-70b-versatile", etc.
summary = spawn_openclaw(payload, depth=1, model="anthropic/claude-opus-4-6", tools=my_tools)
except (ValueError, FileNotFoundError) as exc:
raise # bad depth, missing payload key, or SKILL.md not found — fix the call
if is_error(summary):
print("Child failed:", summary)
else:
result = read_result("/tmp/jwt_helpers_changes.md")
if result is None:
print("WARNING: child did not write expected result file.")
If SKILL.md is not adjacent to spawn_openclaw.py, pass the path explicitly:
import pathlib
summary = spawn_openclaw(payload, depth=1, skill_path=pathlib.Path("/your/path/to/SKILL.md"))
is_error(summary) — handle failures before reading files.read_result(path) to safely read child output; treat None as child failure.| Strategy | When to use | Parent blocks? |
|---|---|---|
| Sequential | Child output is needed for next parent step | Yes, until child done |
| Parallel-gather | Multiple independent children; parent needs all before continuing | Yes, until all done |
| Fire-and-forget | Child works on a separable track; parent has its own work now | No — merge later |
Use spawn_openclaw(payload, depth=1) as shown in Step 4. Read result, check for errors, continue.
import asyncio
from spawn_openclaw import spawn_openclaw_async, is_error, read_result
async def main():
payloads = [
{
"main_task_title": "Generate market research report",
"progress_so_far": "Outline approved. Three sections assigned in parallel.",
"sub_task": "Write 'Competitive Landscape' (600 words). Save to /tmp/section_competitive.md."
},
{
"main_task_title": "Generate market research report",
"progress_so_far": "Outline approved. Three sections assigned in parallel.",
"sub_task": "Write 'Customer Segments' (600 words). Save to /tmp/section_customers.md."
},
{
"main_task_title": "Generate market research report",
"progress_so_far": "Outline approved. Three sections assigned in parallel.",
"sub_task": "Write 'Market Trends' (600 words). Save to /tmp/section_trends.md."
},
]
summaries = await asyncio.gather(
*[spawn_openclaw_async(p, depth=1, model="openai/gpt-4o", tools=my_tools) for p in payloads],
return_exceptions=True,
)
result_paths = [
"/tmp/section_competitive.md",
"/tmp/section_customers.md",
"/tmp/section_trends.md",
]
for summary, path in zip(summaries, result_paths):
if isinstance(summary, BaseException):
print(f"Child raised exception for {path}: {summary}")
continue
if is_error(summary):
print(f"Child failed for {path}:", summary)
continue
content = read_result(path)
if content is None:
print(f"WARNING: no result file at {path}")
else:
print(f"Merging {path} ({len(content)} chars)")
# merge content into parent output ...
The parent delegates a sub-task and immediately continues its own work. The child writes results to a known file path. The parent checks at a planned merge point.
Parent: ──[spawn child]──────────────────────────[merge point]──▶ continue
Child: └──[work independently]──[write result file]──▶ done
import asyncio
from spawn_openclaw import spawn_openclaw_async, is_error, read_result
async def main():
child_result_path = "/tmp/child_analysis.md"
payload = {
"main_task_title": "Refactor authentication module",
"progress_so_far": (
"Audit complete. Parent is now rewriting core auth logic. "
"Child is assigned to analyse test coverage gaps in parallel."
),
"sub_task": (
f"Read `/tmp/audit_report.md`. Identify which functions lack test coverage. "
f"Write a markdown report of gaps to `{child_result_path}`. "
f"Include function name, file, and suggested test cases for each gap."
),
}
# Spawn — returns immediately, child runs in background
child_task = asyncio.create_task(
spawn_openclaw_async(payload, depth=1, model="anthropic/claude-opus-4-6", tools=my_tools)
)
# Parent does its OWN work right now
await do_parent_work()
# Merge point — collect child
try:
summary = await child_task
except (ValueError, FileNotFoundError) as exc:
print("Child raised configuration error:", exc)
return
if is_error(summary):
print("Child failed:", summary)
else:
content = read_result(child_result_path)
if content is None:
print("WARNING: child did not write expected result file.")
else:
await integrate_child_output(content)
async def do_parent_work():
pass # replace with actual parent steps
async def integrate_child_output(text: str):
print(f"Merging {len(text)} chars from child...")
result_path in sub_task — it is the only rendezvous.read_result() — returns None safely on any filesystem error (missing file, permission denied, etc.).What raises vs. what returns an error string:
| Situation | Behaviour |
|---|---|
| Anthropic API error (network, rate limit, etc.) | Returns JSON error string — never raises |
| Empty or non-text API response | Returns JSON error string — never raises |
depth >= MAX_DEPTH | Raises ValueError — programmer error, fix your call |
Missing required payload key (main_task_title, progress_so_far, sub_task) | Raises ValueError — fix your payload |
| Non-JSON-serializable value in payload | Raises ValueError — fix your payload |
| SKILL.md not found | Raises FileNotFoundError — fix your path config |
Always check with is_error() after a successful call. Wrap the call itself in try/except if you need to handle the two programmer-error exceptions gracefully:
try:
summary = spawn_openclaw(payload, depth=1)
except (ValueError, FileNotFoundError) as exc:
print("Configuration error:", exc)
raise # or handle
if is_error(summary):
import json
err = json.loads(summary)
print("Runtime error:", err["error"])
print("Partial results at:", err.get("partial_results")) # may be None
# decide: retry, fallback, abort parent
else:
# success — read result files
result = read_result("/tmp/some_output.md")
if result is not None:
# merge result ...
pass
else:
print("WARNING: child did not write expected result file.")
| Anti-Pattern | Why It's Bad | Fix |
|---|---|---|
Sending the full conversation history as progress_so_far | Wastes tokens; child gets confused | Summarize: only what the child needs |
Including secrets in progress_so_far | Snapshot is sent to Anthropic API and visible to child | Strip API keys, passwords, tokens, and personal data before spawning |
| Passing overly-permissive tools to children | Children gain filesystem or network access beyond what their sub_task needs | Scope tools to minimum required capability; omit tools= if the task only needs text output |
Vague sub_task like "handle the rest" | Child doesn't know what to do | Be explicit: inputs, steps, output location |
| Spawning for a 2-line task | Overhead > benefit | Do it in the parent |
| Not writing a progress snapshot before spawning | Parent loses state if it crashes | Always freeze state first |
Omitting tools= when sub_task requires file I/O | Child is a pure LM — it cannot read or write files; read_result() always returns None | Pass tool definitions or collect results from the summary string instead |
Ignoring is_error() on child summary | Silent failures; parent merges nothing | Always check before reading result files |
| Fire-and-forget with no result file | No rendezvous; parent can't collect output | Always specify result_path in sub_task |
| Awaiting fire-and-forget child immediately after spawn | Defeats the purpose | Put await child_task at the merge point |
Omitting depth= when spawning from inside a child | Depth check never triggers; runaway trees | Always pass depth=current_depth + 1 |
| Child spawning further children without explicit permission | Runaway tree; hard to debug | Only spawn if sub_task explicitly says so |
SPAWN CHECKLIST
───────────────────────────────────────────
[ ] Sub-task is genuinely separable?
[ ] depth= will stay within MAX_DEPTH (default 3)?
[ ] Progress snapshot written (parent state frozen)?
[ ] main_task_title: ≤ 10 words, stable
[ ] progress_so_far: dense summary, no raw dumps
[ ] sub_task: self-contained, explicit result_path
STRATEGY SELECTION
[ ] Parent needs result before next step? → Sequential (A)
[ ] Multiple children, all needed before merge? → Parallel-gather (B)
[ ] Parent has its own work to do right now? → Fire-and-forget (C)
AFTER EVERY SPAWN
[ ] is_error(summary) checked?
[ ] read_result(path) used (handles missing files safely)?
[ ] None result handled — don't merge silently?
FIRE-AND-FORGET EXTRAS
[ ] result_path agreed before spawning?
[ ] merge point placed after parent's own work?