Background Runs
Kick off long-running flows as detached child processes and poll for results.
Some flows take minutes. Some take hours. Neither should hold your conversation hostage.
When you set detach: true on a taskflow call, the runtime spawns the entire DAG as an independent child process and returns immediately with a runId. Your context window stays free. The flow runs to completion in the background, persisting its state after each phase. You check on it later — or never, if you only care about the side effects.
This page covers when to detach, how to poll, what happens to approval gates, and the crash-safety guarantees that keep background runs debuggable.
When to detach
A normal taskflow call blocks your conversation until the final phase returns. That is the right default for most work — you asked for a result, you want it now. But three situations favor detaching:
The flow is long. A multi-hour audit, a large map over hundreds of files, a loop that converges slowly. Blocking your conversation for that duration wastes the interactive session.
The flow is fire-and-forget. A nightly batch, a CI pipeline, a webhook-triggered reindex. You care about the terminal state, not the live transcript.
You want to overlap work. Kick off a slow flow, continue the current conversation, check back later. The parent session is never blocked.
Detaching is a runtime concern, not a flow-definition concern. The same JSON flow can run inline or detached — the detach flag lives on the tool call, not in the flow definition.
Starting a background run
On Pi
Pass detach: true to the taskflow tool. The model can do this automatically when it judges the flow is long-running, or you can be explicit:
Try: "Run the audit flow in the background — I'll check on it later."
Or via the command:
/tf run audit dir=src --detachThe response is immediate:
Taskflow 'audit' started in background (pid: 42871). Run id: 2026-07-06T14-32-01-auditThat runId is your handle. The pid is the OS process id of the detached child — useful if you need to inspect or signal it directly.
On Codex, Claude Code, OpenCode
The MCP server exposes the same detach parameter on the taskflow_run tool. The mechanics are identical: the server spawns a child process and returns the runId in the tool result.
{
"action": "run",
"name": "audit",
"args": { "dir": "src" },
"detach": true
}What happens under the hood
Serialize context. The host writes a small JSON file to a temp directory. It contains the runId, the flow definition name, the resolved args, the working directory, and the path to the host's runner module (so the child can spawn subagents).
Spawn the child. The host calls child_process.spawn with detached: true and child.unref(). The child is a standalone Node process running the detached-runner script. It receives the context file path as argv[2].
Return immediately. The host persists the initial run state (with detached: true and the child's pid), then returns the runId to the caller. The parent conversation is free.
The child runs the DAG. The detached runner reads the context file, re-discovers agents, dynamically imports the host's runner module, and calls executeTaskflow. Each phase runs exactly as it would inline — subagent calls, caching, retry, persistence.
Persist terminal state. When the flow completes (or fails), the child persists the final RunState to disk and exits. The parent can poll the store at any time to see the result.
The child process is genuinely independent. It has its own event loop, its own stderr, and its own lifecycle. Killing the parent does not kill the child — that is by design. The child was spawned with detached: true specifically so it survives the parent session.
Polling and monitoring
A detached run persists its state to disk after every phase. You can check on it from any session in the same project.
Browse run history
/tf runsDetached runs show their pid and detached: true flag. In-progress runs show status: running. Completed runs show their terminal state.
Peek at a phase
/tf peek <runId> <phaseId>This is the same peek command you use for any run. It pulls the phase's persisted output into your context — useful for debugging a gate that blocked or a map that partially failed.
peek is for debugging only. By design it pulls intermediate output into your context — that's the one exception to taskflow's isolation guarantee, and it's opt-in.
Resume a failed run
If a detached run fails (a transient error exhausted its retries, a gate blocked, a subagent crashed), you can resume it from where it stopped:
/tf resume <runId>Resume runs inline by default. If you want the resumed run to also be detached, pass --detach again.
Approval phases in detached mode
Approval phases are human-in-the-loop gates. They pause the flow and wait for a decision. In a detached run, there is no interactive approver — the child process has no TTY, no UI, no way to ask a human.
Approval phases auto-reject in detached mode. The gate records a BLOCK verdict with the reason "(auto-rejected: no interactive approver available)". The run halts at that phase, just as if a human had rejected it.
This is a safety boundary, not a limitation. Approval gates exist to prevent unreviewed work from shipping. Silently bypassing them in a non-interactive context would defeat their purpose. If your flow has approval phases and you want it to run unattended, replace them with gate phases (which are deterministic and don't require human input) or remove them entirely.
The auto-rejection is recorded in the run state. When you poll the run, you'll see:
{
"id": "approve-deploy",
"status": "done",
"output": "(auto-rejected: no interactive approver available)",
"approval": { "decision": "reject", "auto": true },
"gate": { "verdict": "block", "reason": "(auto-rejected: no interactive approver available)" }
}The phase itself is done (it executed), but the gate it carries is block, which halts downstream phases.
Crash safety
Background runs can fail in ways that inline runs cannot: the child process can crash, the runner module can fail to load, the OS can kill the process. taskflow handles each failure mode explicitly so the run is never silently stuck.
Top-level crash guard
The detached runner wraps its entire execution in a top-level try/catch. If an unhandled exception reaches the top level, the catch block persists status: "failed" to the run state before exiting. The run is never left in status: "running" after the child process dies.
Runner module failure
The detached runner dynamically imports the host's runner module (e.g., piSubagentRunner from pi-taskflow). If the import fails — bad path, missing export, compile error — the runner writes a synthetic __detach__ phase with the import error and exits with code 1:
{
"id": "__detach__",
"status": "failed",
"error": "Runner module failed to load: 'pi-taskflow/dist/runner.js' (export 'piSubagentRunner'). See detached-runner stderr for the import error."
}This fail-fast behavior prevents the runner from limping on and failing every phase with the generic "No subagent runner injected" error. The real cause (a bad module path, a missing export) is preserved in the __detach__ phase and in the child's stderr.
Early exit guard
The parent process captures the child's stderr and registers an exit handler. If the child exits with a non-zero code before reaching a terminal state, the parent marks the run failed and writes a __detach__ phase with the captured stderr:
{
"id": "__detach__",
"status": "failed",
"error": "Detached runner exited with code 1: [stderr content]"
}This guard is race-safe: it checks the child's pid and the run's status before writing, so it never clobbers a genuine terminal state the runner may have persisted between spawn and exit.
Spawn failure
If the child process fails to spawn at all (permission denied, missing Node binary), the parent's error handler writes a __detach__ phase with the spawn error. The run is marked failed immediately.
Use cases
Long-running audits
A security audit that scans hundreds of files, runs each through a reviewer, and aggregates findings. Inline, this blocks your conversation for 30+ minutes. Detached, you kick it off and check back when it's done.
{
"name": "security-audit",
"phases": [
{
"id": "discover",
"type": "agent",
"agent": "scout",
"task": "List all TypeScript files under {args.dir}. Output a JSON array of paths.",
"output": "json"
},
{
"id": "review-each",
"type": "map",
"over": "{steps.discover.json}",
"as": "file",
"agent": "security-reviewer",
"task": "Review {file} for security vulnerabilities. Return findings or 'No issues.'",
"concurrency": 8
},
{
"id": "summarize",
"type": "reduce",
"from": ["review-each"],
"agent": "writer",
"task": "Combine these reviews into a prioritized security report:\n{steps.review-each.output}",
"final": true
}
]
}Run it detached:
/tf run security-audit dir=src --detachCheck back later:
/tf peek <runId> summarizeCI/CD pipelines
A flow that runs in a CI job, triggered by a webhook or a cron schedule. The CI job starts the flow, captures the runId, and exits. A separate monitoring process polls the run state and reports the result.
Detached runs are ideal for CI because they don't require an interactive session. The auto-reject behavior for approval phases is a feature, not a bug — it prevents unreviewed work from shipping in automated contexts.
Overlapping work
You're in the middle of a conversation, and you realize you need to run a slow flow. Instead of blocking the conversation, you detach the flow and continue talking. When the flow finishes, you can peek at the result or resume the conversation with the new context.
Limitations
Detached runs trade interactivity for independence. These limitations are by design.
No approval phases. Approval gates auto-reject. If your flow requires human approval, run it inline or replace approval phases with deterministic gates.
No live progress. The parent conversation does not receive live updates. You poll the run state instead. If you need streaming progress, run the flow inline.
Independent lifecycle. The child process survives the parent. If you kill your terminal, the flow keeps running. To stop a detached run, kill the child process by its pid (e.g., kill <pid>). There is no built-in cancel command.
Runner module required. The child process must be able to dynamically import the host's runner module. If the module path is wrong or the module is missing, the run fails fast with a __detach__ phase. This is rarely an issue in practice — the host serializes the correct path automatically.
No requestApproval callback. The detached runner does not inject a requestApproval function. This is the mechanism behind the auto-reject behavior. If you need custom approval logic, run the flow inline.
Runner module injection
taskflow-core is host-neutral. It cannot spawn pi, codex, or claude itself — it doesn't know which host is running it. The host adapter (pi-taskflow, codex-taskflow, etc.) injects a runTask function that knows how to spawn subagents for that host.
In a detached run, the child process needs the same injection. The host serializes the path to its runner module into the context file:
{
"runId": "2026-07-06T14-32-01-audit",
"defName": "audit",
"args": { "dir": "src" },
"cwd": "/Users/beta/work/my-project",
"runnerModule": "/Users/beta/.pi/extensions/pi-taskflow/dist/runner.js",
"runnerExport": "piSubagentRunner"
}The detached runner dynamically imports runnerModule and extracts the runnerExport (defaulting to "piSubagentRunner"). If the import succeeds and the export has a runTask function, the runner is ready. If not, the runner fails fast with a __detach__ phase.
This design means the same detached-runner.ts script works for every host — Pi, Codex, Claude Code, OpenCode. The host-specific logic lives in the runner module, not in the detached runner itself.
Debugging a failed detached run
When a detached run fails, the run state tells you why. Check the __detach__ phase first:
/tf peek <runId> __detach__If the __detach__ phase exists, it contains the crash reason — a runner module import error, a spawn failure, or the child's stderr. If the __detach__ phase does not exist, the failure was a normal flow failure (a gate blocked, a phase exhausted its retries, a budget exceeded). Check the other phases to find the root cause.
You can also inspect the child's stderr directly if you have access to the parent's logs. The parent captures stderr and includes it in the __detach__ phase on early exit.
Next
Using taskflow on Pi
The full Pi workflow, including saved flows and run history.
Resume and Caching
How taskflow persists run state and resumes from where it stopped.
Approval Phases
Human-in-the-loop gates and why they auto-reject in detached mode.
Last updated on
Was this helpful?
Help us improve the docs or ask a question in the community.