Tasks & Fan-out
Agents are not limited to answering the task they were given. From inside a run, an agent can write results back to the tracker item and spawn child tasks that drive their own workflows — all through plain text markers in its output, no API access required. This page covers those markers and the task model behind them.
The internal task model
The canonical unit of work in Apiary is the task. A task usually starts life as a source item (a GitHub issue, a Plane work item) — that link is called a binding — but tasks can also be created internally by other tasks, forming a lineage tree:
Task: "Implement billing module" (bound to issue #42)
├─ Task: "Add invoices table" (spawned)
├─ Task: "Create invoices API" (spawned)
└─ Task: "Wire dashboard UI" (spawned)
Each task fans out to one or more workflow instances (every matching trigger,
or the workflow named by its spawner). The task settles when its last
instance reaches a terminal state. The dashboard's Tasks tab and
apiary task <id> show the full tree: bindings, lineage, instances, steps,
and logs.
Output markers
Apiary scans agent output for these markers:
| Marker | Purpose |
|---|---|
APIARY_OUTPUT: {…} |
Structured output validated against the step's schema |
APIARY_PUBLISH_BEGIN … END |
Publish human-facing text back to the source item |
APIARY_SPAWN_BEGIN … END |
Create a child task and run a named workflow on it |
APIARY_MEMORIZE_BEGIN … END |
Save a task note or durable fact to agent memory |
Because they are plain text, they work identically across every runner — the agent does not need credentials or tracker-specific tooling.
APIARY_PUBLISH — write-back
Anything the agent wraps in a publish block is written back to the task's source bindings (e.g. posted as an issue comment):
APIARY_PUBLISH_BEGIN
Triage summary: the spike in 5xx responses traces to the new cache layer.
Mitigation is in progress; a follow-up task collects diagnostics.
APIARY_PUBLISH_END
The step-level publish field controls this: auto (default — write back
when the agent emits a payload) or off (never write back, even if a payload
is present).
This is the precise, agent-authored alternative to the global
settings.result_comment flag, which posts the entire final output.
APIARY_SPAWN — child tasks
An agent spawns a child task by emitting a JSON payload naming a workflow:
APIARY_SPAWN_BEGIN
{"workflow": "collect-logs", "title": "Collect logs for incident", "input": {"severity": "high"}, "key": "incident/collect-logs"}
APIARY_SPAWN_END
| Field | Description |
|---|---|
workflow |
Workflow ID to run on the child — started by name, not by trigger routing |
title |
Child task title |
input |
Arbitrary JSON handed to the child's steps as task input |
key |
Optional idempotency key — see Spawn deduplication |
The parent is always the spawning task — lineage cannot be forged from agent output.
The step-level spawn field controls how the engine treats the request:
spawn: auto(default) — fire-and-forget: the child is created and dispatched; the parent step finishes without waiting.spawn: await— the step blocks until the spawned task reaches a terminal state, and the child's failure fails the step. Use this when the parent's later steps depend on the child's work.
Named workflows (without triggers)
The spawned workflow typically has no trigger — it can only be started
by name, never by polling:
workflows:
- id: collect-logs
description: "Collect diagnostic logs (spawned via APIARY_SPAWN)."
steps:
- id: collect
agent: investigator
prompt: "Gather the relevant logs and metrics for the incident described in the task input."
Spawn deduplication
Spawns are idempotent per parent task. When key is set, re-running the
spawning step (or the whole workflow) with the same key resolves to the child
that already exists instead of creating a duplicate. When key is omitted,
a key is derived from (workflow, title, input) — so byte-identical spawns
still dedup.
Supply an explicit key whenever the title or input may vary between runs —
e.g. a spec that decomposes into a fixed set of sub-tasks should use stable
keys like "<spec>/<sub-task>", so a re-decomposition can never produce two
of the same child.
Pair the spawning workflow's trigger with
once: true so the workflow
itself also runs at most once per task — otherwise every poll re-dispatches
the decomposition while the source item still matches the trigger.
Task-level hooks
Per-workflow on_complete/on_fail fire once per instance. The
top-level tasks: block fires once per task, when the last of its
fanned-out instances reaches a terminal state:
tasks:
on_complete: # all instances succeeded
add_labels: [ai-complete]
on_fail: # at least one instance failed
set_state: blocked
add_labels: [ai-failed]
The hook applies to every source binding on the task. This is the right place for "the task as a whole is done/broken" signals when several workflows process the same item.
Putting it together
A triage workflow that publishes a summary, spawns a follow-up, and waits for it:
workflows:
- id: incident-triage
trigger:
priority: 5
exclusive: true # claim incidents alone — no fan-out to other workflows
once: true # never re-triage the same incident
match:
source: my-repo
labels: [incident]
steps:
- id: triage
agent: investigator
prompt: |
Assess the incident. Write a short summary back to the issue
(APIARY_PUBLISH block) and spawn a `collect-logs` follow-up task
(APIARY_SPAWN block) with a stable key.
publish: auto
spawn: await # the triage isn't done until diagnostics exist
on_complete:
set_state: in review