The workload is a Unix filter

Strip any AI program down to its unit of work and what is left is input → black box → output. Inference is that shape. So are transcription, synthesis, description, embedding, extraction, classification. Three properties come with it, and none of them is a design choice:

  • Stateless. f(in) → out. Continuity does not live in the component; it lives in what you hand it.
  • Event-driven. Nothing runs until a stimulus arrives.
  • Burstable. A tiny duty cycle — wall-clock dominated by waiting, with a brief flare of compute when something lands.

That is precisely the contract of a Unix filter — read stdin, transform, write stdout, exit — arrived at from the AI side rather than borrowed from tradition. The composition layer therefore already exists, and we invent none of it: the node is a process, the connector is a pipe.

So each capability is one orthogonal command rather than a library binding, and it composes with every other program on the machine, AI or not:

$ echo 'two cities' | llm -d /dev/llm/fixture/two-cities \
      --output-format typed --output-encoding json | chat-render
→ get_weather {"city":"Recife"}
→ get_weather {"city":"Olinda"}

llm opened a device and streamed a typed event stream. chat-render is an ordinary filter that has never heard of a model — it takes a ChatEvent stream and draws it. Neither knows the other exists, and either can be replaced without telling the other. The fixture device needs no credential, so that line runs on a machine with no key in it.

Two channels, and the second one is typed

A filter is only as composable as the thing on the wire. llm writes plain text by default, which is right for a person and useless for a program, so the typed channel is the one that composes: --output-format typed emits the raw event stream — deltas, function calls, thinking, the completion — and --output-encoding json puts it on the wire as one JSON record per line, which every tool you already own can read.

Both flags are load-bearing. typed alone emits length-prefixed protobuf, since protobuf is the default encoding, and a downstream stage expecting JSON says so immediately.

Two stages, never three

There are two record types on this wire, and the difference decides how you may plumb them. ChatEvent is the live stream, one frame per delta. ChatMessage is the assembled turn. chat-codec fold is the reduction between them: it consumes a ChatEvent stream and emits exactly one ChatMessage.

Which means fold and chat-render are each the final stage of a branch, and the two do not chain. A renderer that consumes events cannot read an assembled message; the mismatch is one of type, not of encoding, and no flag reconciles it. So: pipe into chat-render to watch a turn, or into chat-codec fold to keep one. Never both in a row.

One trap worth naming, because it is the shell’s and not ours: a broken stage in the middle of a pipeline never reaches the exit code, since the shell reports the last stage. Validate a pipeline by its output — an example checked by exit code passes green with a dead middle.

Nothing on that wire is an integration

The pieces in these examples were not written for one another and share no library. What they share is a record type on a byte stream, which is the only contract the operating system has ever needed. A block written in Rust and a block written in Python are the same kind of thing to it.

And the shapes available are the shell’s rather than ours: linear, fan-out with tee, fan-in, conditional — and the loop, which is the next page and the one that decides whether this is a platform or an SDK.