odek can spawn focused sub-agent OS processes for parallel, isolated work on independent sub-tasks. Each sub-agent is its own operating system process — not a goroutine, not a lightweight thread — with its own heap, its own config, and its own context window.
# Spawn a sub-agent directly
odek subagent --goal "Build JWT auth middleware in internal/middleware/auth.go" \
--context "Uses gin, user model at internal/models/user.go"
# Machine-readable JSON on stdout, human-readable progress on stderr
# → {"status":"success","summary":"...","files_changed":[...],"tokens_used":4200,"iterations":3}| Approach | Isolation | Panic Safety | Memory | Testability |
|---|---|---|---|---|
| Goroutine (in-process) | Shared heap | One panic kills all | Shared | Easy |
| OS process | Full isolation | Independent | Separate | Via CLI |
| Docker container | Kernel isolation | Independent | Heavy | Slow |
Sub-agents use OS processes (os/exec) — real isolation without Docker overhead. A panic in a sub-agent exits only that sub-agent (exit code 3). The parent agent continues unaffected.
┌─────────────────────┐
│ Parent Agent │
│ (ReAct loop) │
└────────┬────────────┘
│ delegate_tasks({ tasks: [...] })
│
▼
┌─────────────────────────────────┐
│ delegateTasksTool │
│ ──────────────────── │
│ • Writes each task to temp │
│ file (avoids CLI arg limits) │
│ • Spawns odek subagent │
│ per task via exec.Command │
│ • Semaphore for concurrency │
│ • Collects JSON from stdout │
│ • Returns formatted summary │
└──┬──────────┬──────────┬────────┘
│ │ │
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ SA 1 │ │ SA 2 │ │ SA 3 │ ← OS processes (parallel)
└──────┘ └──────┘ └──────┘
Decompose a task when it has clear, independent sub-tasks with minimal cross-cutting concerns:
- Good: "Build a user CRUD API" → { "Create user model", "Create handler", "Create routes" }
- Bad: "Refactor the auth module to use JWT" — a single goal with tight coupling, better done in one shot
- Bad: "Fix bug X" or "Review PR Y" — too small, overhead of spawning a sub-agent isn't worth it
Each sub-agent gets a fresh context — no parent history, no conversation state. It starts from the system prompt and its goal. Provide enough context so it doesn't need to re-discover the project structure.
The delegate_tasks tool is available in CLI, REPL, Web UI, Telegram, and headless scheduled runs. The agent calls it automatically when it identifies independent sub-tasks. odek mcp does not register it.
{
"status": "success", // "success" or "error"
"summary": "Built JWT auth middleware with HS256 signing",
"summary_truncated": true, // present only when the headline was cut at 2048 runes
"summary_runes": 3120, // original headline length — present only when truncated
"files_changed": ["internal/middleware/auth.go"],
"tokens_used": 4200,
"iterations": 3,
"parent_session": "20260519-abc123" // echoed back when --parent-session was passed
}The summary is the headline channel: the child's final answer, capped at
2048 runes. Truncation is tail-keep — the END of the answer is preserved
(verdicts, next actions, key decisions), and a leading … marks the cut.
When summary_truncated is set, the parent-side render appends
headline truncated (2048 of N runes shown) — fetch artifacts via artifact_read or re-run with a narrower goal (the artifact_read half is
omitted in processes that do not have the tool, i.e. mid-tree parents).
Both fields are omitempty: results that fit the headline produce byte-identical
JSON to older versions.
The parent_session field is omitted when --parent-session was not supplied.
Use it to correlate sub-agent results back to the originating parent session
in logs, dashboards, or audit pipelines.
On failure:
{
"status": "error",
"error": "create agent: odek: no API key provided",
"summary": "",
"files_changed": [],
"tokens_used": 0,
"iterations": 0
}- Deserializes the task array from the LLM's tool call
- Validates: rejects empty, >8 tasks, or malformed JSON
- Writes each task to a temp file (
odek-task-*.json) — avoids CLI argument length limits (useful for 100KB+ context) - Spawns
odek subagent --task <file> --quietfor each task - Limits concurrency via a process-wide buffered-channel semaphore (default: 3, max: configurable) — sibling delegate_tasks calls in one batch and concurrent
odek servesessions share the same bound, since provider plans are account-wide - Collects JSON result from each subprocess stdout
- Returns a formatted summary with all sub-agent results tagged by task number
Direct invocation for testing and debugging:
# Basic
odek subagent --goal "List files in /tmp"
# With context
odek subagent --goal "Build auth middleware" --context "Uses gin framework"
# From file (for large context)
odek subagent --task /path/to/task.json
# With timeout and iteration limits
odek subagent --goal "Refactor main.go" --timeout 60 --max-iter 10
# Silent mode (suppresses emoji progress on stderr)
odek subagent --goal "Run tests" --quiet
# With parent session ID (for cross-session context)
odek subagent --goal "Continue refactoring" --parent-session "20260519-abc123"| Code | Meaning | When |
|---|---|---|
0 |
Success | Task completed normally, status: "success" in JSON |
1 |
Task error | Agent failed with a recoverable error, status: "error" in JSON |
2 |
Timeout | Context deadline exceeded (controlled by --timeout) |
3 |
Setup failure | Invalid flags, missing config, or internal panic |
Every value-taking flag (--goal, --context, --task, --parent-session, --profile, --timeout, --max-iter) is a setup failure when its value is missing, so a wrapper with an empty variable cannot silently drop a requested capability profile.
| Flag | Default | Description |
|---|---|---|
--goal <string> |
— | Required unless --task specified. The sub-agent's goal. |
--context <string> |
"" |
Background context (file paths, design decisions) |
--task <file> |
— | JSON file with the task spec (see Task file format). Mutually exclusive with --goal. |
--timeout <sec> |
1800 | Max seconds the sub-agent may run before being killed (hard max 1800 = 30 min) |
--max-iter <n> |
15 | Max think→act cycles (hard max 100) |
--quiet |
false | Suppress emoji progress on stderr |
--stream |
false | Emit tool events as JSON lines on stdout while the run progresses (the final JSON result is also printed on stdout at exit) |
--parent-session <id> |
— | Session ID from the parent (for context relay) |
--profile <name> |
— | Select an operator-defined capability profile (top-level profiles config). The profile's max_risk/allowlist/tool filter override the operator's global config; unknown names fail the task |
For large prompts that exceed CLI argument length limits, use the --task flag with a JSON file:
{
"goal": "Create a user registration endpoint in handlers/user.go",
"context": "Uses gin. DB connection at internal/db/db.go. User struct in models/user.go: {ID, Email, Password, CreatedAt}. Password must be bcrypt-hashed. Returns 201 with user JSON on success.",
"guidance": "",
"trust_level": "trusted",
"max_risk": "local_write",
"profile": "",
"parent_trust": "",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"base_url": "",
"budget": {"max_runtime_seconds": 0, "max_tool_calls": 0, "max_input_tokens": 0, "max_output_tokens": 0, "max_cost_usd": 0}
}All keys except goal are optional. trust_level, max_risk, profile, and model mirror the delegate_tasks task fields; parent_trust records the spawning agent's trust. Omitted models inherit the parent's model. Model availability is validated by the provider.
Through delegate_tasks, the model can select only a model ID on the parent's provider. The tool stamps the inherited provider and base URL and passes the API key through the existing file descriptor channel. Provider, endpoint, and credentials are not selectable tool arguments.
In share mode, budget carries the parent's remaining budget; the child enforces min(operator limits, inherited values) (zero values are ignored). A shared cost budget requires both input and output prices for the selected model, resolved through an exact limits.model_prices match with per-field flat-price fallback; missing prices fail closed.
The delegate_tasks tool always uses this file-based approach internally.
Pure JSON. Always parseable — even on errors. The parent delegate_tasks tool parses this stream line-by-line (json.Unmarshal per line):
// Success
{"status":"success","summary":"Created handlers/user.go with POST /users","files_changed":["handlers/user.go"],"tokens_used":3200,"iterations":5}
// Error
{"status":"error","error":"no API key provided","summary":"","tokens_used":0,"iterations":0}Emoji-prefixed progress for terminal users:
🔧 Sub-agent: Build JWT auth middleware
🧠 Need to understand the project structure...
🔧 shell: ls internal/
✅ Sub-agent complete: 4.2s, 3200 tokens, 5 iterations
Suppressed with --quiet.
- At iteration boundaries, when the run crosses 50% / 75% / 90% of its
iteration, wall-clock, tool-call, token, or cost budget, the engine
appends a one-line budget hint to the corrections message and emits a
budget_warningsignal, so the model can pace itself and conclude cleanly instead of being cut off mid-work (subagent.announce_budget, default on). Parent runs use the top-levelannounce_budgettoggle (also default on;--no-announce-budget/ODEK_ANNOUNCE_BUDGET=false); the two keys are independent. - The wall-clock budget is two-stage: a finalization window (min(15s,
timeout/8)) before the hard kill is reserved for a bounded
partial-progress summary — a timed-out sub-agent returns
status: "partial",partial_reason: "time_budget"and a usable report instead of nothing. Iteration-budget exhaustion reportspartial_reason: "iteration_budget"; a hard execution budget reportsstatus: "budget_exhausted"(exit code 4). - Delegation depth is capped (
subagent.max_depth, default 2, tracked viaODEK_SUBAGENT_DEPTH): a sub-agent at the cap refuses to fan out further. - With
subagent.budget_inherit: "share", the parent writes its remaining budget into each task file and the child enforces min(operator limits, parent remaining) — a near-exhausted parent can no longer spawn children with fresh headroom.
A sub-agent's system prompt is a fixed, code-defined constant (subagentSystem in
cmd/odek/subagent.go). It composes three parts: a focused-task identity block, the
same invariant security pillar the parent prompt carries (shared securityPillar:
Safety, Execution provenance, and Indirect Prompt Injection sections), and sub-agent
amendments that adapt principal-facing rules to a child with no principal channel —
skip-and-report replaces confirmation, scope is the declared task, a value taken from
untrusted content is used only when the declared task calls for it, a denied operation
is never retried another way (nor one the request says was denied), the declared task
stands in for the principal in the pillar's precedence order, and injection
findings go into the final report. Nothing the parent supplies is ever spliced into
it — not the parent's system prompt, not --system, not IDENTITY.md.
All parent-supplied strings travel in the user request instead, assembled by
buildSubagentRequest():
Task: <goal>
Approach (guidance from the orchestrator):
<guidance> # optional — how to tackle it, NOT an identity
Context:
<context> # optional — file paths, API contracts, decisions
This separation is deliberate. The goal/guidance/context may contain text the
parent ingested from untrusted sources (fetched pages, MCP output, files). Keeping them
out of the system prompt means a prompt-injection payload can never rewrite the
sub-agent's identity or strip its safety rules — at worst it's a hostile request, which
the fixed SAFETY block tells the model to treat as data.
Sub-agents are autonomous by design and never prompt for approvals —
not even trusted ones. Prompt-class operations are denied (non_interactive: deny is forced for every sub-agent); the operator allowlist (exact
pre-approved invocations) is the only path to prompt-class operations. That
includes the network_upload class (file-backed request bodies, credentials on
the command line, mutating HTTP methods, local-to-remote copies, listeners and
tunnels): network_egress stays inside a network_egress cap, an upload does
not, and untrusted tasks deny both.
Untrusted sub-agents never load mcp_servers (MCP adapters do not
danger-classify). Trusted and capped children do, under the child's
DangerousConfig cap. See MCP.md.
Denials are not silent: each child reports the policy denials it observed
in its result (denials: [{tool, class, reason}], capped at 20 with
denials_total for the full count), and the parent emits one
subagent_denied runtime event per denial — so the parent model can adapt
("network denied → fetch it myself") and operators get uniform visibility.
Trust is non-increasing downward: the delegate tool stamps the parent's
effective trust into the task file (parent_trust), and the child runs at
min(parent_trust, trust_level). A task tree rooted in untrusted content
cannot launder itself into trusted children. Only the exact label trusted
counts as trusted; an empty or unrecognised trust_level or parent_trust
(Untrusted, trusted , low, ...) fails closed to untrusted.
The trust_level a model requests is honoured only on a clean run.
delegate_tasks clamps every requested child to untrusted when the parent
run is tainted. What taints (external content only):
- any tool result that can carry external content — the engine wraps each
tool's output as
tool:<name>and records an ingest, so the first such call taints the run (file, shell, network, search, session, memory, skill, background and MCP tools, and every embedder tool); @-refs,--ctxfiles, attachments, Telegram forwards/voice/captions/media,session_searchresults, sub-agent results, background-job notices;- project instructions: a loaded
AGENTS.mdis repository content, so a run in a repository with anAGENTS.md(andno_agents_mdoff) is tainted from its first iteration; - any registered MCP tool (see below);
- resuming a session that was ever tainted (the session's sticky
untrusted_ingestedflag survives context and write-time trimming).
What does not taint: the engine's own derived context — the plan, the rolling digest (compaction), the memory block, re-wrapped persisted system messages, progress summaries, effect evidence, reviewed skills, extended-memory recall, and the return-after-break summary. (Episode recall does taint: an episode summarises another session and is admitted by the memory gate's per-tool rule, which is weaker than this taint.) These blocks are wrapped too, but they derive from history that was already taint-tracked when it entered; a tool can never produce their labels.
Pure built-in calls do not taint either. A first-party call whose output
comes only from the model's own arguments or from operator-only config keeps
its untrusted boundary, under the engine-derived label pure_tool:<name>, but
records no ingest and does not taint. The pure calls are: math_eval;
base64 inline encoding (content without decode/string; file mode and
decoding taint); list_subagent_profiles; list_tools unless the listing
shows an MCP server introduced by the project ./odek.json; and plan.
For list_tools, purity covers the operator-written MCP command and argument
strings only, not what those commands resolve to: the tool never runs them.
config_view is not pure, because the resolved view includes values a project
./odek.json may set, and neither are diff, json_query, tree,
checksum, head_tail (each reads the filesystem), clarify, send_message
and speak. Purity is honoured only for registered first-party types
(tool.RegisterPureOutputType, in an internal package): an embedder or MCP
tool that implements PureOutputFor, a type that embeds a built-in, and any
tool behind untrustedToolWrapper all still taint.
So trusted delegation is available when a run delegates before any tainting
tool call, in a session that has never ingested external content, with no MCP
server and no AGENTS.md loaded. MCP tool names, descriptions and schemas are third-party text in
the tool catalogue — no ingest is ever recorded for them, yet a poisoned
description can steer a delegate_tasks call. The secure default wins: while
any MCP server is loaded, trusted delegation is unavailable and every child
runs untrusted (no MCP, no bg_*, dangerous classes denied). To delegate
trusted work, run without MCP servers (set "enabled": false on each server,
or use a config without mcp_servers). Sub-agents that load MCP servers
themselves clamp their own children the same way.
Capability profiles let the operator define named permission envelopes
in the top-level profiles config (see CONFIG.md): a task
selects one via profile: "name" and the profile's max_risk/allowlist/
tools override the corresponding global config for that sub-agent.
Profiles are operator-authored (project config is stripped) and selecting
one cannot lift the non-interactive deny or the trust lockdown — policy,
not escalation. A curated starter set of 21 task profiles (builders,
reviewers, judges, researchers, orchestrators, …) ships in
profiles.template.json.
When a task selects no profile, the operator's default envelope applies:
a built-in default profile (max_risk: "local_write") unless overridden via
subagent.default_profile (a defined profile name, or "none" to disable).
The precedence chain is --profile flag > task-file profile > default
envelope, and "none" is honored only from operator config — a delegating
parent can narrow its choice but never strip the operator's envelope. The
parent model discovers available profiles (including descriptions and the
effective default) via the built-in list_subagent_profiles tool.
A profile may be selected by the operator's direct odek subagent --profile
flag or by the parent via the task file (delegate_tasks's profile
field); the flag outranks the task file. Unknown names fail the task twice
over: delegate_tasks validates before spawning a child, and the child
re-validates against its own resolved config (defense in depth — also
covering manual --task invocation).
Whenever the child's effective trust is untrusted — the parent set
trust_level: "untrusted", omitted trust_level, or is itself untrusted (trust never
increases downward) — the entire request body is wrapped in an <untrusted_input> fence
with a preamble telling the model to treat it as data, not instructions — in addition to
the permission clamp applied by applySubagentTrust (see SECURITY.md).
The fence keys on the effective trust, not on the label the parent's model declared.
To influence how a sub-agent works, pass guidance (not a system prompt):
{
"tasks": [
{
"goal": "Review middleware/auth.go for security issues",
"guidance": "Look for token-validation gaps, timing attacks, and secret exposure."
},
{
"goal": "Fix the OOM in parser.js",
"guidance": "Find the root cause before changing code; prove the fix with a test."
}
]
}There is no system field — it was removed precisely because it let parent-controlled
(and possibly injection-tainted) text become the sub-agent's identity. ODEK_SYSTEM /
config system also do not apply to sub-agents; the boundary is intentionally fixed.
The temp file written by delegate_tasks carries the request inputs, never a system prompt:
{
"goal": "Create a user registration endpoint in handlers/user.go",
"context": "Uses gin. DB connection at internal/db/db.go.",
"guidance": "Validate inputs; return structured errors.",
"trust_level": "trusted",
"max_risk": "local_write",
"profile": "",
"parent_trust": "trusted",
"budget": {"max_runtime_seconds": 0, "max_tool_calls": 0, "max_input_tokens": 0, "max_output_tokens": 0, "max_cost_usd": 0}
}Config in odek.json:
{
"subagent": {
"max_concurrency": 3,
"timeout_seconds": 1800,
"max_iterations": 15
}
}| Field | Default | Description |
|---|---|---|
max_concurrency |
3 (global default; falls back to it when unset here) | Max sub-agents running in parallel (max 8) |
timeout_seconds |
1800 | Default timeout per sub-agent; hard max 1800 (values above are clamped) |
max_iterations |
15 | Default max think→act cycles per sub-agent |
max_depth |
2 | Delegation nesting cap (1 = a sub-agent may not delegate further) |
announce_budget |
true |
Inject budget-awareness hints (50/75/90% of budget) and announce effective limits in the sub-agent system prompt. Independent of the parent announce_budget config key |
budget_inherit |
"operator" |
Budget inheritance for child runs: "operator" (full operator limits) or "share" (child enforces min of operator limits and the parent's remaining budget) |
The
subagentsection is operator-only: a project-level./odek.jsoncannot set it (ignored with a warning), so a cloned repository cannot relax sub-agent guards.
| Risk | Mitigation |
|---|---|
| Sub-agent hijacking | Sub-agents are never prompted by the parent/user — they receive structured goal/context strings. No instruction injection path. |
| Runaway processes | Hard timeout (--timeout, default 1800s = 30 min, hard max 1800s). Context cancellation kills via os.Process.Kill(). |
| Resource exhaustion | Concurrency semaphore (max max_concurrency). Sequential spawning. No fork bomb. |
| Panic propagation | Each sub-agent is an OS process. Panic exits only that process with code 3 — parent sees the JSON error and continues. |
| Temp file leakage | Each task file is defer os.Remove()'d after subprocess exit. |
The sub-agent system has three test layers:
| Layer | Runner | What's verified |
|---|---|---|
| Contract tests | go test ./cmd/odek/ |
Flag parsing, JSON stdout protocol, exit codes, tool schema, config parsing, fixed system-prompt trust boundary (buildSubagentRequest carries goal/guidance/context; system prompt unaffected by parent input; untrusted fencing) |
| E2E tests | ODEK_E2E=1 go test ./cmd/odek/ -run "TestE2E_" |
Real subprocess spawning, tool → binary pipeline, stderr protocol, concurrency, timeouts, fixed system-prompt boundary (no threading — parent input never reaches the system prompt) |
| Full suite | go test -race ./... |
Every package, race-detector clean |
E2E tests:
- Build the
odekbinary once viaTestMain - Test the full pipeline:
tool.Call()→exec.Command("odek", "subagent", ...)→ JSON stdout → parse - Require no live LLM provider: setup-failure tests verify error contracts, and a local mock provider verifies selected model IDs and model-specific cost accounting.
- Validate: binary exists, stderr emoji protocol, quiet mode, 100KB+ task files via temp files, missing binary graceful degradation
The main agent can set tasks[].model in delegate_tasks independently for
each child. Omit it to inherit the parent's current model. A single batch can
mix explicit model selections and inherited models without changing the
parent or sibling tasks.
For example, ask the main agent: “Run an adversarial review with a Flash model, and have another subagent review the test coverage using your current model.” For a parent configured to use DeepSeek, the tool arguments can be:
{
"tasks": [
{
"goal": "Review the authentication change for bypasses and report findings",
"model": "deepseek-v4-flash",
"max_risk": "safe",
"context": "Review the implementation and tests. Do not modify files."
},
{
"goal": "Review authentication test coverage and report missing cases",
"max_risk": "safe",
"context": "Review the tests independently. Do not modify files."
}
]
}The first child requests deepseek-v4-flash; the second inherits the parent's
model. Use an exact model ID supported by your provider. “Flash” and “cheaper”
are task guidance, not built-in model aliases or automatic price selectors.
The provider validates model availability; odek does not discover available
models or switch providers for a task. Provider, endpoint, and credentials
remain inherited from the parent.
Explicit model names must contain 1–256 characters after trimming surrounding
spaces and must not contain control characters. Empty or invalid names reject
the batch before any child starts. Omit model to request inheritance.
Model selection does not change capability profiles, trust restrictions, or
execution limits. With a shared cost budget, both input and output prices must
resolve for the selected model through limits.model_prices or the flat-price
fallback; otherwise the child fails before making an LLM call. Configure
per-model prices when comparing spend across different models.
User: "Create a REST API for a todo app in Go with JWT auth"
Parent agent thinks:
- This has 3 independent parts: model, auth, handlers
- Each can be built in parallel
- Calls delegate_tasks
Tool call:
delegate_tasks({ tasks: [
{ goal: "Create Todo model with CRUD in models/todo.go",
context: "Uses GORM, fields: {ID, Title, Done, CreatedAt}" },
{ goal: "Create JWT auth middleware in middleware/auth.go",
context: "Uses gin, HS256 signing, secret from env JWT_SECRET" },
{ goal: "Create todo handlers in handlers/todo.go",
context: "Uses gin, depends on models/todo.go. Routes: GET/POST /todos, PUT/DELETE /todos/:id" }
]})
3 sub-agents run in parallel:
SA1: odek subagent --task /tmp/kode-task-001.json --quiet
SA2: odek subagent --task /tmp/kode-task-002.json --quiet
SA3: odek subagent --task /tmp/kode-task-003.json --quiet
All complete in ~5s (2 batches of 2, max_concurrency=3). The parent receives
each result rendered as fields — not raw JSON:
SA1: status: success · ~4200 tokens
summary: Created models/todo.go — GORM CRUD with all fields per spec.
files changed: models/todo.go
SA2: status: success · ~3800 tokens
summary: JWT middleware done — HS256, secret from JWT_SECRET env.
files changed: middleware/auth.go
SA3: status: success · ~5100 tokens
summary: Handlers wired for GET/POST/PUT/DELETE /todos.
files changed: handlers/todo.go
Parent synthesizes: "Created 3 files:
- models/todo.go — Todo model with CRUD
- middleware/auth.go — JWT auth middleware with HS256
- handlers/todo.go — REST handlers
Total: 8 files changed, 13100 tokens, 5s parallel"
The result contract is two-channel: the headline (≤ 2048 runes, tail-kept —
the END of the child answer survives so verdicts and next actions reach the
parent; a leading … marks the cut) carries status;
the bulk rides files. The child is told this at request time: deliverables
larger than a headline go as FLAT files into its per-task staging directory
(.odek-artifacts/<task_id>/ inside the workspace — nested directories are
discarded). The runner relocates them to ~/.odek/artifacts/<session>/<task>/,
measures sha256/size, and returns odek.artifact-ref/v1 references with the result.
The parent sees one metadata line per artifact — id, media type, size, short hash, owning task, first-line summary — plus inlined content for small text artifacts (≤ 32 KiB) within a 128 KiB per-call inline budget (largest first; the rest degrade to their metadata line, never dropped). Everything larger is readable on demand via artifact_read:
artifact_read({ "id": "report" }) # first 64 KiB
artifact_read({ "id": "report", "offset": 65536 }) # continue paging
artifact_read is a parent-side tool; the model passes an id, never a path — resolution goes through the session registry of validated refs (ids are namespaced per parent session: a session never resolves or lists another session's artifacts, and ids never collide across sessions), and each read reports the owning task. Ids derive from filename stems, and first occurrence wins: if two tasks stage files with the same stem (e.g. both write report.md), the first task keeps the plain id and later duplicates register under <id>.t<task> aliases (report.t2, report.t3, …) — probing past any live entry, including real stems that collide with the alias namespace. Rendered artifact lines always show the effective id plus the task number, so the parent copies an id artifact_read can actually resolve. Refs that fail validation (wrong hash, path escape) are dropped with an explicit flag and never rendered.
Lifecycle: deleting a session deletes its artifacts (all paths — CLI, API, Telegram, retention sweep); the storage janitor backstop sweeps orphans after maintenance.artifacts_max_age_hours (default 24 hours, 0 = keep forever).
Clients (Web UI, bodek) consume sub-agent telemetry through three surfaces:
live subagent_state WS frames, GET /api/subagents (registry snapshot —
same fields as the frames), and the framed result envelope. All v2 fields are
omitempty: old clients ignore them, and absent means "unavailable", never
zero.
Phases. A task's phase moves queued → started → active → finished.
queued is emitted by the parent the moment delegate_tasks accepts a task —
before it acquires a concurrency slot — so an 8-task delegation on a 2-slot
operator reads 2 live · 6 queued. Queued tasks count as neither active nor
finished in the stats.
State frame fields. In addition to the v1 fields (task_id, task_idx,
run_key, phase, status, step, iterations, tool,
duration_seconds, tokens_used):
| Field | Carries | Notes |
|---|---|---|
goal |
task goal text | redacted, clamped to 2048 chars server-side (model-controlled input) |
profile |
profile id | declared value while queued; the child's effective (post-clamp) value from started on |
max_risk |
effective risk ceiling | declared while queued; effective (post operator-profile resolution) from started |
budget_seconds, budget_iterations |
enforced caps | 0/absent = uncapped; present on started and active |
budget_cost_usd |
enforced cost cap | only when cost enforcement is active (cap + resolved prices) |
cost_usd |
server-side cost estimate | cumulative on live frames; final on finished |
artifacts |
[{id, path, bytes}] |
terminal only — metadata from the result envelope; content never rides the wire |
The framed result envelope adds cost_usd (final) and artifacts
(the full odek.artifact-ref/v1 refs — a superset of the frame metadata).
Result authentication. Commands a child runs can write lines to the
child's stdout, so the parent does not trust any line that merely looks like a
result. For every spawn the parent mints a random nonce and hands it to the
child over a private inherited descriptor (ODEK_SUBAGENT_FRAME_FD names the
descriptor, never the value); the child reads and closes it at startup, before
any tool runs, and stamps it as auth on its framed result. The parent takes
the first framed result carrying the nonce as the task result and ignores
every later result line, forged or duplicate. Only that authenticated frame may
report usage for budget accounting: without one, the shared-budget grant is
settled as unreported (never refunded), and an unauthenticated result line is
kept for display only: its status becomes unverified (the claimed status is
kept as claimed_status), the completion event and registry report
unverified, and the parent announces the terminal state itself.
On Linux the child also makes itself non-dumpable (prctl(PR_SET_DUMPABLE, 0)) before reading its inherited descriptors, so a background command it
left running cannot reopen its stdout, frame or key descriptor through
/proc/<pid>/fd to steal the frame and replay the nonce. As a consequence
sub-agent processes write no core dumps and cannot be attached to by same-uid
debuggers or tracers (gdb, strace -p); root or CAP_SYS_PTRACE still can.
Commands the child runs are not affected (the flag resets on execve).
Cost semantics (authoritative — do not re-derive). cost_usd values are
computed server-side with the exact /api/usage math (per-million prices
resolved for the child's model over provider-reported token totals). Zero or
absent means "prices not configured" — clients must render cost as
unavailable, never $0. Totals are split, not folded: the serve-lifetime
tokens_in/tokens_out/estimated_cost_usd in /api/usage cover parent
turns only (children are separate odek processes with their own sessions);
sub-agent spend is broken out under subagents.tokens_used and, as of v2,
subagents.cost_usd (lifetime sum of final per-task estimates). A client
total including sub-agents is therefore estimated_cost_usd + subagents.cost_usd — or, per batch, the sum of cost_usd over final
result envelopes only (cumulative state-frame values double-count on
replay).
Blocking model — deny, never prompt. Sub-agents can never park waiting
on an approval: the child runs non-interactive with deny forced for every
operation that would prompt, and denied operations are listed in the result's
denials array (capped, with a separate total). There is no
waiting_approval state and none is planned; a card showing running with
denied operations is working through them or will finish with them reported.
Clients should treat error / timeout / cancelled as the only sticky
outcomes.
- Keep goals small — one file, one concern per sub-agent. If a goal spans 3 files, it's probably not a good decomposition boundary.
- Provide file paths in context — saves the sub-agent from crawling the project tree.
- Check the trade-off — spawning a sub-agent takes ~500ms. Don't delegate tasks that complete in 2 tool calls.
- Observation: sub-agents work best for greenfield work (creating new files). Refactoring existing code often has too many implicit dependencies.
The unified operational log records delegated task lifecycle and timings when
the parent CLI has logging enabled. Child records share the conversation's
session_id and user-visible turn_id, while each invocation has its own
run_id; root_run_id, parent_run_id, and task_id describe the delegation
tree. Terminal task status and the parent's observed child-process exit status
are separate fields. An unfinished tree row does not prove a child is still
running. Records expire according to logging.max_age_hours. See
Runtime logging.
When the parent's sandbox is active, every child spawned by delegate_tasks is started with ODEK_SANDBOX=true and sandboxes itself; if the child's sandbox cannot start the child fails instead of running on the host. The parent's container posture (read-only workspace, image, network, memory, cpus, user) and the project-sandbox approvals it was granted this session (keys bound to the project directory and the Dockerfile.odek content hash) ride in the task file, so a child's container is never weaker than the parent's and a y (once) approval covers the children it spawned; a changed Dockerfile is not covered. A parent running without a sandbox leaves children to their own configuration.
The parent's configured tool filter (the allow and deny sets, including --tool / --no-tool) is also written to the task file; the child applies it on top of its own config and profile filter, so delegation never regains a tool the parent was configured without. A parent with no filter writes none, and the child keeps its normal tool set.
{ "type": "object", "properties": { "tasks": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "object", "properties": { "goal": { "type": "string" }, // Required. Specific goal for this sub-agent. "context": { "type": "string" }, // Optional. Background: file paths, API contracts. "guidance": { "type": "string" }, // Optional. How to approach the task — delivered in // the request, NOT the system prompt (which is fixed). "trust_level": { "type": "string", "enum": ["trusted", "untrusted"] }, // Optional. Trust level of the goal/context strings. // "untrusted" when any portion derives from external // content (fetched pages, files outside CWD, MCP output); // untrusted tasks run with stricter approval defaults. "max_risk": { "type": "string", "enum": ["safe", "local_write", "system_write", "destructive", "code_execution", "network_egress", "network_upload", "install", "blocked"] }, // Optional cap on the allowed risk class. Calls above the // cap are denied without prompting — use for read-only // fan-out tasks. Rank, low to high: safe, local_write, // install, network_egress, network_upload, // code_execution, system_write, persistence, unknown, // destructive, blocked — so network_egress allows // fetches but not uploads or code execution. // Operator profiles.*.max_risk also // accepts persistence, unknown, and unread_exec // (validated at load; unread_exec is enforced by the // trust lockdown, not this clamp). "profile": { "type": "string" }, // Optional. Operator-defined capability profile name // (top-level `profiles` config). Its max_risk, allowlist, // and tool filter OVERRIDE the operator's global config // for this sub-agent. Unknown names fail the task. "model": { "type": "string", "minLength": 1, "maxLength": 256 } // Optional. Model ID supported by the parent's provider; // omitted inherits the parent's model. Provider, endpoint, // and credentials always remain those of the parent. }, "required": ["goal"] } }, "description": { "type": "string" } // Optional. Logged for debugging. }, "required": ["tasks"] }