The default mental picture of an AI system is one agent with many tools. The other shape is many small nodes with one job each, wired together through a durable queue. In that shape the model call is just what happens inside some of them. This is that shape built end to end: a topic goes in, a briefing on its neighboring disciplines comes out. Exactly one agent loop runs in the entire system.
github.com/tacoda/iii-concept-expanderANTHROPIC_API_KEY/expand returns immediately · poll /result to watch the stage advanceBoth shapes solve this problem. They fail differently, and the failure modes are the reason to choose deliberately rather than by habit.
flowchart TD
req["POST /expand {topic}"] --> api["api-worker"]
api -- expand.plan --> planner["planner-worker · LLM: topic → research angles"]
planner -- expand.research --> researcher["research-worker · agent loop: tools::web_search ×N"]
researcher -- expand.neighbors --> neighbors["neighbors-worker · LLM: adjacent disciplines"]
neighbors -- expand.synthesis --> synth["synthesis-worker · LLM: related topics + how they relate"]
synth -- "state: done" --> read["api-worker reads state"]
read --> res["POST /result {job_id}"]
class api,planner,neighbors,synth,read warn
class researcher pass
class req,res dim
Notice how little of this is agentic. Four of the five model-calling nodes are one
constrained LLM call with a fixed input and output shape. Only
research-worker runs a real loop, because only research needs one:
the number of searches is not knowable in advance. Everything else is a transformation that
happens to use a model.
| Property | One agent, six tools | Six nodes, one queue |
|---|---|---|
| Time to first version | Faster. One file, one prompt | Slower. Six workers and a config |
| Where a failure lands | Somewhere in a long transcript | On one node, with its topic and job id |
| Retry granularity | The whole task | The failed stage |
| Crash mid-run | The run is gone | The queue still holds the handoff |
| Cost control | One model for everything | Per-node model and effort |
| Long-running work | Bounded by one session | Bounded by nothing; it is a queue |
| Where reasoning happens | Everywhere, continuously | Only where you put it, which is also the cost |
That last row cuts both ways. A pipeline cannot decide it needs a seventh stage, because you decided the stages when you drew the graph. If the work varies in structure from run to run, one agent with good tools will beat a fixed graph. That mismatch is not one durability can fix. Reach for this shape when the stages are stable and the content is not.
The infrastructure a pipeline like this needs is queue, state, HTTP, configuration, and observability. In iii those are workers you declare rather than services you stand up.
# Only managed infrastructure workers live here. The 6 custom pipeline nodes
# (api, planner, research, neighbors, synthesis, tools) self-connect as local
# processes so their ANTHROPIC_API_KEY comes from the host env, never git.
workers:
- name: iii-observability
- name: configuration
- name: state
- name: queue
- name: http
Five lines. Each one expands into a real subsystem configured in config/. The
HTTP server binds 0.0.0.0:3111 with a request timeout and a concurrency limit,
and the state store enables trigger fan-out. The queue picks a persistence adapter, and
observability turns on traces, metrics, and logs under the service name
concept-expander.
The comment matters as much as the config. This file deliberately leaves the pipeline nodes
out, because they need ANTHROPIC_API_KEY and git tracks the file. They
self-connect as local processes and read the key from the host environment. To run a node as
an engine-managed microVM instead, ship an iii.worker.yaml: the manifest's
env: map injects the key.
Declared infrastructure goes in the repo. Anything that needs a secret stays out of it until a manifest exists to inject that secret. That split is visible in this config, and getting it wrong is how keys end up in git history.
A node is not a class hierarchy or a framework subclass, but a connection, a handler, and a trigger. Five Python nodes and one TypeScript node in this pipeline share the same three-line skeleton.
"""Node 1: break the incoming topic into concrete research angles."""
import os
import anthropic
from iii import register_worker, InitOptions
from iii_helpers.observability import Logger
worker = register_worker(
os.environ.get("III_URL", "ws://localhost:49134"),
InitOptions(worker_name="planner-worker"),
)
logger = Logger()
client = anthropic.Anthropic() # ANTHROPIC_API_KEY from host env
def get_job(job_id: str) -> dict:
return worker.trigger(
{"function_id": "state::get", "payload": {"scope": "jobs", "key": job_id}}
) or {}
def publish(topic: str, job_id: str) -> None:
worker.trigger(
{"function_id": "iii::durable::publish",
"payload": {"topic": topic, "data": {"job_id": job_id}}}
)
Everything a node can do to the outside world goes through worker.trigger with
a function_id. Reading state is state::get, and writing it is
state::set. Handing off is iii::durable::publish, and calling
another node's capability is that node's function id. One verb, and the id says which
subsystem you are talking to.
The consequence is that nodes have no imports of each other. planner-worker does
not know research-worker exists: it publishes to expand.research
and stops caring. Because of that decoupling, you can restart, redeploy, or rewrite one node
on its own, in a different language. One of these six is TypeScript and the
other five are Python for that reason.
Nothing checks that expand.research has a subscriber. Publish to a queue topic
nobody listens to and the job stops advancing, with no error anywhere. That silence is the
cost of a string-keyed graph, which is why the stage field in rung 3 is not
optional bookkeeping.
Every message on the wire is {"job_id": "..."}. The actual work product lives
in the state store under that id. Each node reads it, adds its piece, and advances the
stage.
const jobId = randomUUID();
logger.info('accepted expand job', { jobId, topic });
await worker.trigger({
function_id: 'state::set',
payload: {
scope: 'jobs', key: jobId,
value: { status: 'pending', topic, stage: 'plan' },
},
});
// Hand off to the first pipeline node via the durable queue.
await worker.trigger({
function_id: 'iii::durable::publish',
payload: { topic: 'expand.plan', data: { job_id: jobId } },
});
return {
status_code: 202,
body: { job_id: jobId, status: 'pending' },
headers: { 'Content-Type': 'application/json' },
};
The 202 is the design. The request does not wait for research to finish, and it
could not: this pipeline runs for minutes. The caller gets an id, the queue drives the rest,
and /result reports where it has got to.
/result is just reading it, and so is debugging.
The stage field is what makes a stalled run diagnosable. A job sitting at
research for ten minutes tells you which node to look at. It also names the
stage whose subscriber never started, so you learn why nothing is happening. Without that
field, a pipeline that stops looks exactly like a pipeline that is slow.
research-worker is the only node that cannot know in advance how much work it
needs. It gets a real agent loop, and the tool that loop calls is a function id on a
different worker.
@beta_tool
def web_search(query: str) -> str:
"""Search the web for current information about a query.
Args:
query: A focused search query.
"""
logger.info(f"web_search -> tools-worker: {query!r}")
return worker.trigger(
{"function_id": "tools::web_search", "payload": {"query": query},
"timeout_ms": 170_000}
) or ""
def research_handler(payload: dict) -> None:
...
runner = client.beta.messages.tool_runner(
model=MODEL,
max_tokens=8000,
output_config={"effort": "high"},
tools=[web_search],
system=(
"You are a research agent. Investigate the topic along the given angles, "
"using the web_search tool as needed to ground yourself in current, factual "
"information. Produce concise notes capturing key concepts, findings, and any "
"fields or disciplines the topic touches. Do not write a final essay yet."
),
messages=[{"role": "user", "content": f"Topic: {topic}\n\nResearch angles:\n{angle_list}"}],
)
final = runner.until_done()
findings = "".join(b.text for b in final.content if b.type == "text")
job["findings"] = findings
job["stage"] = "neighbors"
set_job(job_id, job)
publish("expand.neighbors", job_id)
The tool function body is one worker.trigger. The model thinks it is calling
web_search. The real event is a message to tools-worker,
which owns the search capability for the whole system. Swapping the search provider then changes
one node, and no other node notices. The generous
timeout_ms is there because a cross-node tool call is a network round trip, not
a function call.
"Do not write a final essay yet." Without that line, a capable model reads "research this topic" and delivers a polished writeup. That writeup then becomes the input to three more stages, each of which wants to write its own. The instruction that holds a stage inside its scope is usually a prohibition, not a description, and every node's prompt here carries one.
Agent loops are expensive and hard to observe. Four of these six nodes do not need one, so they do not get one. Decide per node instead of making the whole system agentic because one part of it has to be. That choice is most of what keeps this shape affordable and debuggable.
You reach the same pipeline over HTTP, from the CLI, or from a script. The CLI hits one node at a time, and the script does the whole thing. The middle one is the one you will use most while building.
export ANTHROPIC_API_KEY=sk-ant-...
# terminal 1 — start the engine + managed infra workers
iii --config config.yaml
# terminal 2 — start the 6 pipeline nodes (installs deps on first run)
./scripts/dev.sh
# one-shot: kick off a topic, poll to completion, print the summary
./scripts/expand.sh "cellular automata"
The interesting entry point is not the HTTP one. Try this instead:
iii trigger http::expand --json '{"body":{"topic":"cellular automata"}}'
iii trigger http::result --json '{"body":{"job_id":"<id>"}}'
You can invoke any node's function by id with a synthetic payload, skipping the HTTP layer
and earlier stages. So a broken synthesis stage is reproducible. Trigger
synthesis against a job id that already has findings, and skip the four
minutes of research. In a monolithic agent the equivalent is
replaying a transcript and hoping.
Logger helper, so logger.info("research complete; handing to neighbors node") lands correlated with the span that emitted it.
memory and sampling is 1.0. Correct for development, wrong for production on both counts, and worth changing before this ever handles real volume.
This shape is not better than one agent with good tools, only better at some things and worse at others. The honest version of that list is short.
Durability protects the handoff, not the content. If neighbors-worker returns
a confident list of subtly wrong disciplines, the queue delivers that wrongness to
synthesis with perfect reliability. Then the briefing at the end is fluent and incorrect.
Every node is a place where a model can be plausibly wrong. Still, the topology changes
none of that risk. It changes only how fast you can find which node did it.
Known stages, unknown content: make the wiring durable, and put the agent loop only where the uncertainty lives.