spawn
Run a child step in an isolated context with optional spawn-local context layers.
Quick Example
import { callModel, spawn } from '@noetic-tools/core';
const isolated = spawn({
id: 'sub-agent',
child: callModel({
id: 'researcher',
model: 'openai/gpt-4o',
instructions: 'Research the topic thoroughly.',
}),
});What It Does
spawn runs a child step in an isolated context. The child starts with an empty ItemLog by default, giving it a fresh conversation history and preventing it from polluting the parent context.
You can attach spawn-local context layers via the optional context field. These layers use onSpawn hooks to provide items to the child and onReturn hooks to transform results back to the parent. Spawn-local context fully replaces parent layer propagation, ensuring complete isolation.
This is the primitive for sub-agents, delegation, and sandboxed execution.
The child's tokens and cost are charged to the parent as they are spent, so the parent's ctx.tokens / ctx.cost cover the whole sub-agent tree — including spend from a child that throws. See Usage roll-up.
API Reference
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique step identifier |
child | Step<TContext, I, O> | Yes | The step to execute in isolation |
context | ContextConfig | ContextLayer[] | No | Spawn-local context layers (use the context() builder for typed access) |
timeout | number | No | Timeout in milliseconds |
steers | 'forward' | 'hold' | No | 'forward' lets the child apply steers sent to the session while it runs. Default 'hold'; see Steers. |
subprocess | SubprocessAdapter | No | Per-step adapter override; see SubprocessAdapter Routing below. |
Context Layers
The context field accepts either a raw ContextLayer[] array or a ContextConfig object produced by the context() builder. The builder provides typed access to layer state and is the recommended approach.
Spawn-local context layers control what the child sees and how results are transformed when the child completes.
onSpawnhooks provide items to the child's initial context. Each layer can inject items (e.g., relevant conversation history, cached knowledge, instructions) into the child's emptyItemLog.onReturnhooks transform the child's results before they flow back to the parent. Layers can summarize, filter, or restructure the output. If anonReturnhook throws or times out, the spawn still succeeds: it returns the result as it stood before that hook, and the failure is reported as aspawn_summary_failedlayer diagnostic and aspawn.summary_failedspan event.
import { callModel, createMessage, spawn } from '@noetic-tools/core';
import type { ContextLayer } from '@noetic-tools/core';
interface ResearchState {
lastResearch?: string;
}
const contextLayer: ContextLayer<ResearchState> = {
id: 'context-layer',
name: 'Context Provider',
slot: 100,
scope: 'execution',
hooks: {
onSpawn: async ({ parentState }) => ({
childState: parentState,
items: [createMessage('Here is background context for your research.', 'user')],
}),
onReturn: async ({ parentState, result }) => ({
parentState: { ...parentState, lastResearch: String(result) },
result,
}),
},
};
const withContextLayers = spawn({
id: 'research-agent',
child: callModel({
id: 'researcher',
model: 'openai/gpt-4o',
instructions: 'Research the topic thoroughly.',
}),
context: [contextLayer],
});Parent Updates to Live Children
A child can react when its parent changes a layer's state while the child runs: a coordinator revises its plan, or a finished sibling's result is merged into the parent. Give the layer an onParentUpdate hook. It runs in the child each time the parent writes that layer's state, with the state the parent wrote and the child's own state. Return { childState } to adopt the change, { items } to tell the child's model about it, or nothing to ignore it.
import { createMessage } from '@noetic-tools/core';
import type { ContextLayer } from '@noetic-tools/core';
interface PlanState {
steps: string[];
}
const sharedPlan: ContextLayer<PlanState> = {
id: 'shared-plan',
name: 'Shared Plan',
slot: 100,
scope: 'execution',
hooks: {
onSpawn: async ({ parentState }) => ({
childState: structuredClone(parentState),
}),
onParentUpdate: async ({ parentState }) => ({
childState: structuredClone(parentState),
items: [createMessage(`The plan changed: ${parentState.steps.join(', ')}`, 'developer')],
}),
},
};- Who hears it. Every live
spawnchild and every runninginParallelpath, from the moment it starts until it returns.harness.detachedSpawnruns noonSpawn/onReturn, so its context hears nothing; a step run in another process by a subprocess adapter hears nothing either. - What counts as a write. Any write of the parent's state for that layer:
store()and other hooks that return state, layer functions, and anonReturnmerge. Mutating a state object in place is not a write. - Background. The parent's write returns at once and never fails because of a child. Each child takes its updates one at a time, in write order; a delivery is not ordered against the child's own hooks.
- Errors. A hook that throws or exceeds
timeouts.onParentUpdate(default 5 s) is logged as a diagnostic and the update is dropped. Each call opens alayer.hookspan under the child's span. - When the child finishes. It stops taking updates before
onReturnruns; a delivery already running finishes first, so the merge sees its result.
Timeout
Set a maximum execution time for the child. If the timeout is exceeded, the spawn throws an error.
const spawned = spawn({
id: 'time-limited',
child: myStep,
timeout: 3e4, // 30 seconds
});Steers
A steer sent to the session while the child runs stays queued by default and reaches the parent's next model call after the child returns. The child is isolated, and in a fan-out no single child is the obvious target. Set steers: 'forward' to let the child's model calls take steers as the parent's would:
const worker = spawn({
id: 'worker',
child: longTask,
steers: 'forward',
});A nested spawn forwards further only if it also sets 'forward'.
Real-World Example: Research Delegation with Context Layers
import { callModel, createMessage, loop, spawn, until } from '@noetic-tools/core';
import type { ContextLayer } from '@noetic-tools/core';
interface BriefingState {
topic: string;
findings?: unknown;
}
const briefingLayer: ContextLayer<BriefingState> = {
id: 'research-briefing',
name: 'Research Briefing',
slot: 100,
scope: 'execution',
hooks: {
onSpawn: async ({ parentState }) => ({
childState: null,
items: [createMessage(`Research the following topic: ${parentState.topic}`, 'user')],
}),
onReturn: async ({ parentState, result }) => ({
parentState: { ...parentState, findings: result },
result,
}),
},
};
const researchAgent = spawn({
id: 'delegated-research',
child: loop({
id: 'research-loop',
steps: [callModel({
id: 'researcher-llm',
model: 'openai/gpt-4o',
instructions: 'You are a research assistant. Use tools to find information.',
tools: [searchTool, fetchTool],
})],
until: until.noToolCalls(),
maxIterations: 10,
}),
context: [briefingLayer],
timeout: 6e4, // 60 seconds
});Tracing
The child runs under a spawn span, a child of the enclosing span, with the spawn step's id in noetic.node.id. The child's model calls, tool calls, and loop verdicts land on it. It ends when the child settles: a failure adds error: true and noetic.error.kind, a cancellation adds only noetic.error.kind: 'cancelled'. See Observability.
Detached Spawn
For background sub-agents that run concurrently, use harness.detachedSpawn(). The parent continues immediately and receives a DetachedHandle to track the child.
import type { DetachedHandle } from '@noetic-tools/core';
const handle = harness.detachedSpawn(subAgentStep, input, ctx);
// handle.status === 'running' — parent continues immediately
// Poll status
if (handle.status === 'completed') {
console.log(handle.result);
}
// Or await the result
const result = await handle.await(); // blocks until done
const result = await handle.await(5_000); // throws on timeoutIsolating background work from the parent's session
By default, detachedSpawn reuses the parent's threadId and resourceId,
so any items the child appends land in the parent's session.accumulatedItems
and replay in the parent's next turn. For long-running background sub-agents
whose work should NOT pollute the parent's history (e.g. a separate research
teammate), pass an overrides argument:
const handle = harness.detachedSpawn(subAgentStep, input, ctx, {
threadId: `teammate-${agentId}`,
});The child runs against a fresh per-teammate session log; the parent's history
remains uncontaminated. The handle and its .await() / .status semantics
are unchanged.
DetachedHandle API
| Property | Type | Description |
|---|---|---|
id | string | Unique handle identifier (child context ID) |
status | 'running' | 'completed' | 'failed' | Current execution status |
result | O | undefined | Child output (set when completed) |
error | string | undefined | Error message (set when failed) |
await(timeout?) | Promise<O> | Wait for completion, optionally with timeout |
Async Sub-Agent Pattern
Combine detached spawn with the loop inbox channel for a full async delegation workflow:
import { channel } from '@noetic-tools/core';
import { z } from 'zod';
const inbox = channel('agent-inbox', { schema: z.string(), mode: 'queue' });
// In a tool's execute function — use the parent ctx passed to execute(args, ctx):
const handle = harness.detachedSpawn(subAgentStep, task, parentCtx);
handle.await().then((result) => {
void harness.send(inbox, `[Sub-agent done] ${result}`, parentCtx);
});
// The parent loop uses the inbox to wake when results arrive:
const agentLoop = {
kind: 'loop',
id: 'async-agent',
steps: [llmStep],
until: until.noToolCalls(),
inbox,
parkTimeout: 3e4,
};Dynamic Delegation via Tool Calling
Give the LLM both a sync and async delegation tool. The LLM decides at runtime which to use based on the task — blocking when it needs the result immediately, or launching in the background when it can continue working.
Forward toolCtx.ctx rather than calling harness.createContext(), so the child keeps its depth, thread, layers, and trace parent. harness.run() runs a step on the context you pass it and opens no boundary of its own, so the sync tool wraps the sub-agent in spawn(...) to give it a child context; detachedSpawn() creates one itself.
import { callModel, channel, spawn, tool, until } from '@noetic-tools/core';
import type { ContextData } from '@noetic-tools/core';
import { z } from 'zod';
const inbox = channel('agent-inbox', { schema: z.string(), mode: 'queue' });
// Tool 1: Sync — blocks until sub-agent finishes, returns result directly
// A tool's execute receives a ToolExecutionContext as its second argument;
// reach the harness and the current Context through it (toolCtx.harness / toolCtx.ctx).
const delegateTool = tool({
name: 'delegate',
description: 'Run a sub-agent and wait for its result. Use when you need the answer now.',
input: z.object({ task: z.string() }),
output: z.string(),
execute: async (args: { task: string }, toolCtx) => {
const child = callModel<ContextData, string, string>({
id: 'sync-sub', model: 'openai/gpt-4o', instructions: 'Answer concisely.',
});
return toolCtx.harness.run(spawn({ id: 'sync-spawn', child }), args.task, toolCtx.ctx);
},
});
// Tool 2: Async — launches sub-agent in background, notifies via inbox when done
const launchTool = tool({
name: 'launch_agent',
description: 'Launch a sub-agent in the background. Use when you can continue other work.',
input: z.object({ task: z.string() }),
output: z.object({ agentId: z.string() }),
execute: async (args: { task: string }, toolCtx) => {
const child = callModel<ContextData, string, string>({
id: 'async-sub', model: 'openai/gpt-4o', instructions: 'Answer concisely.',
});
const handle = toolCtx.harness.detachedSpawn(child, args.task, toolCtx.ctx);
// Notify the parent loop when the sub-agent finishes
handle.await().then(
(result) =>
void toolCtx.harness.send(inbox, `[Agent ${handle.id} done] ${result}`, toolCtx.ctx),
(err) =>
void toolCtx.harness.send(inbox, `[Agent ${handle.id} failed] ${err}`, toolCtx.ctx),
);
return { agentId: handle.id };
},
});
// The agent loop: LLM chooses which tool to call based on the situation
const agent = {
kind: 'loop',
id: 'smart-delegator',
steps: [callModel({
id: 'orchestrator',
model: 'openai/gpt-4o',
instructions: `You are an orchestrator. You have two delegation tools:
- delegate: blocks and returns the result. Use for tasks you need answered before continuing.
- launch_agent: runs in background. Use when you can keep working while it runs.`,
tools: [delegateTool, launchTool],
})],
until: until.noToolCalls(),
inbox, // wakes the loop when background agents finish
parkTimeout: 3e4,
};The LLM sees both tools and their descriptions, then decides per-task:
- "I need this answer to continue" → calls
delegate(sync, blocks) - "Research this while I handle other things" → calls
launch_agent(async, continues)
When a background agent finishes, its result arrives via the inbox channel as a developer message. The loop wakes, the LLM sees the result, and incorporates it.
SubprocessAdapter Routing
Every runCode, spawn, and harness.detachedSpawn dispatches through a SubprocessAdapter. The harness defaults to createInMemorySubprocessAdapter() so zero-config callers keep their existing in-process behaviour; swap in a different adapter to change how a step actually runs.
import { callModel, spawn } from '@noetic-tools/core';
import { createFileStorage, createLocalSubprocessAdapter } from '@noetic-tools/platform-node';
// Run this spawn out-of-process with durable handle manifests.
const subprocess = createLocalSubprocessAdapter({
storage: createFileStorage({ root: `${process.env.HOME}/.noetic/subprocess` }),
});
const isolated = spawn({
id: 'sub-agent',
child: callModel({ id: 'researcher', model: 'openai/gpt-4o' }),
subprocess, // per-step override
});Resolution Precedence
When the interpreter dispatches a runCode or spawn, it resolves the adapter in this order:
- Per-call override — the
overrides.subprocessargument toharness.detachedSpawn(step, input, ctx, overrides). - Per-step override — the
subprocessfield onStepRunCodeorStepSpawn. - Harness default —
harness.subprocess(defaults tocreateInMemorySubprocessAdapter()).
Other step kinds (callModel, invokeTool, conditional, inParallel, withContext, loop) always use the harness default.
Step Registry
When an adapter crosses a process boundary, the child runtime must locate the step body by id. Every step builder auto-registers its result in a shared registry (@noetic-tools/core/runtime/step-registry). lookupStep(stepId) is the cross-process contract — the child process imports the same entry module as the parent and resolves the step by id before executing it.
Durable Handle Manifests
Adapters configured with a durable StorageAdapter persist a manifest per handle: handleId, stepId, serializedInput, executionId (the child context), parentExecutionId (the context that spawned it — pass it to harness.restore()), the request's metadata tags, and transport-specific identity (pid + pidStarttime for OS children; socketPath for IPC). Rehydrated handles carry the tags and both ids on handle.metadata. On parent restart, adapter.listLive() rediscovers the still-running children and adapter.reattach(handleId) rebinds each handle. See the Durability page for the full restart flow.
withContext vs spawn
Use withContext when you want to attach context layers to a subtree without isolating the child context. The child shares the parent's ItemLog and conversation history -- layers simply become available to all descendant steps.
Use spawn when you need full context isolation. The child starts with an empty ItemLog, and onSpawn / onReturn hooks control data flow across the boundary.
withContext | spawn | |
|---|---|---|
| Child context | Shared with parent | Isolated (empty ItemLog) |
| Context layers | Inherited by descendants | Spawn-local only |
| Use case | Attach layers to a subtree | Sub-agents, delegation, sandboxing |
Related
- withContext -- attach context layers without context isolation.
- inParallel -- parallel execution without context isolation.
- Loop & Until -- use a loop as a spawn child for iterative sub-agents.
- Overview -- how spawn fits into the eight primitives.