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, where 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, and there is exactly one agent loop 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.
POST /expand {topic}
│
▼
api-worker ──publish──► expand.plan
│
planner-worker (LLM: topic → research angles) ──► expand.research
│
research-worker (agent loop: tools::web_search ×N) ──► expand.neighbors
│
neighbors-worker (LLM: adjacent disciplines) ──► expand.synthesis
│
synthesis-worker (LLM: related topics + how they relate) ──► state: done
│
POST /result {job_id} ◄── api-worker reads state
Notice how little of this is agentic. Four of the five model-calling nodes are a single
constrained LLM call with a fixed input and a fixed output shape. Only
research-worker runs a real loop, because only research genuinely 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 step |
| 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; you decided the stages when you drew the graph. If the work genuinely varies in structure from run to run, a single agent with good tools will beat a fixed graph, and no amount of durability makes up for a topology that does not match the problem. Reach for this 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 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, 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. The pipeline nodes are deliberately not
declared here, because they need ANTHROPIC_API_KEY and this file is in git. They
self-connect as local processes and read the key from the host environment. When you want a
node running as an engine-managed microVM instead, it ships an iii.worker.yaml
and the key is injected through the manifest's env: map.
Declared infrastructure goes in the repo. Anything that needs a secret stays out of it until there is a manifest to inject the secret properly. 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. It is 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. Writing it is
state::set. Handing off is iii::durable::publish. 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. That is what makes a node independently restartable, independently
deployable, and independently rewritable in a different language, which is exactly why one
of these six is TypeScript and the other five are Python without that being a problem.
Nothing checks that expand.research has a subscriber. Publish to a topic
nobody listens to and the job simply stops advancing, with no error anywhere. That is the
cost of a string-keyed graph, and it is why the stage field on the job object
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, and 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, and a job sitting at a
stage whose subscriber never started tells you why nothing is happening. Without it, 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; what actually happens is a message to tools-worker,
which owns the search capability for the whole system. Swapping the search provider is a
change in one node that no other node notices, and 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 it, a capable model reads "research this topic" and delivers a polished writeup, which then becomes the input to three more stages that each want to write their own. The instruction that keeps a pipeline stage in its lane is usually a prohibition, not a description, and each node's prompt in this repo 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. Deciding per-node, rather than making the whole system agentic because one part of it has to be, is most of what keeps this shape affordable and debuggable.
The same pipeline is reachable over HTTP, from the CLI one node at a time, and through a script that does the whole thing. The middle one is the one you will actually use 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. It is this:
iii trigger http::expand --json '{"body":{"topic":"cellular automata"}}'
iii trigger http::result --json '{"body":{"job_id":"<id>"}}'
Any node's function can be invoked by id with a synthetic payload, without the HTTP layer and
without the stages before it. That means a broken synthesis step is reproduced by triggering
synthesis against a job id that already has findings, instead of re-running four
minutes of research to get back to the failure. 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. It is better at specific things, and worse at others, and the honest version of that list is short.
Durability protects the handoff, not the content. If neighbors-worker returns
a confident list of disciplines that are subtly wrong, the queue delivers that wrongness to
synthesis with perfect reliability, and the briefing at the end is fluent and incorrect.
Every node is still a place where a model can be plausibly wrong, and nothing about the
topology changes that. It only changes how easily you can find which node did it.
When the stages are known and the content is not, make the wiring durable and put the agent loop only where the uncertainty actually lives.