NOETIC
Framework
Operators

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

PropertyTypeRequiredDescription
idstringYesUnique step identifier
childStep<TContext, I, O>YesThe step to execute in isolation
contextContextConfig | ContextLayer[]NoSpawn-local context layers (use the context() builder for typed access)
timeoutnumberNoTimeout in milliseconds
steers'forward' | 'hold'No'forward' lets the child apply steers sent to the session while it runs. Default 'hold'; see Steers.
subprocessSubprocessAdapterNoPer-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.

  • onSpawn hooks 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 empty ItemLog.
  • onReturn hooks transform the child's results before they flow back to the parent. Layers can summarize, filter, or restructure the output. If an onReturn hook throws or times out, the spawn still succeeds: it returns the result as it stood before that hook, and the failure is reported as a spawn_summary_failed layer diagnostic and a spawn.summary_failed span 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 spawn child and every running inParallel path, from the moment it starts until it returns. harness.detachedSpawn runs no onSpawn/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 an onReturn merge. 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 a layer.hook span under the child's span.
  • When the child finishes. It stops taking updates before onReturn runs; 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 timeout

Isolating 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

PropertyTypeDescription
idstringUnique handle identifier (child context ID)
status'running' | 'completed' | 'failed'Current execution status
resultO | undefinedChild output (set when completed)
errorstring | undefinedError 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:

  1. Per-call override — the overrides.subprocess argument to harness.detachedSpawn(step, input, ctx, overrides).
  2. Per-step override — the subprocess field on StepRunCode or StepSpawn.
  3. Harness default — harness.subprocess (defaults to createInMemorySubprocessAdapter()).

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.

withContextspawn
Child contextShared with parentIsolated (empty ItemLog)
Context layersInherited by descendantsSpawn-local only
Use caseAttach layers to a subtreeSub-agents, delegation, sandboxing
  • 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.

On this page