- Published on
chord: composing AI models as Unix filters
- Authors

- Name
- Mohamed Abdelrahman
I prefer to run my models locally. They’re free, and my data never leaves the machine — no API keys, no network calls, nothing to leak.
Usually what I want is a pipeline. Say I want to transcribe some speech and then summarize it — a speech-to-text step followed by a summary step, two models chained back to back. Each runtime has its own CLI and its own flags, so wiring them together always meant writing glue.
But I already have a tool for composing things together — the Linux pipe. That’s
the whole idea behind chord: every
model is a filter, and you connect them with |.
# The idea
Every model reads on stdin and writes on stdout. In the common case it reads one format and writes another, so each transform is a verb with an input kind and an output kind:
| Verb | Kinds | Engine |
|---|---|---|
stt |
audio → text | whisper.cpp |
tts |
text → audio | Supertonic / ONNX |
text |
text → text | llama.cpp |
chat |
text · image · audio → text | mistral.rs (multimodal) |
see |
image → text | llama.cpp (vision) |
draw |
text → image | stable-diffusion.cpp |
pack · unpack |
assemble / inspect a message | — |
The full picture is a little more general. What actually flows over the pipe is a
message: an ordered list of typed parts. A transform is a function from one
message to another. The mono-modal verbs above (stt, tts, text, see,
draw) are just the special case of one part in and one part out; chat is the
general case — text, an image, and a clip of audio in a single prompt. And when a
message is a single part, it’s carried over the pipe as raw bytes, so a stage
stays a plain filter and chord draw > out.png still does exactly what it looks
like.
# The syntax
Every transform has the same shape:
chord <verb> [input] [--flags]
It reads the input file — or stdin if you leave it off — and writes its result
to stdout. Options are plain named flags (--prompt, --system, --steps), and
chord <verb> --help lists them. That uniformity is the whole trick: because
each one is a stdin → stdout filter, the shell pipe composes them for free.
# The pipeline shortcut
Chaining with | runs each stage as its own process, which reloads a model per
stage. When that matters, chord pipeline runs the whole chain in one
process, with stages separated by :::
chord pipeline \
see photo.jpg --prompt "what is this?" :: \
text --system "translate to German" :: \
tts \
| aplay
Each stage is written exactly as it would be on its own — verb [flags]. The
first stage reads the file or stdin, each later stage reads the previous one’s
output, and the last writes stdout. Same pipeline, models loaded once.
# Examples
Ask a question out loud, get a spoken answer back:
cat question.wav | chord stt | chord text | chord tts | aplay
Read a sign in a photo and hear it back in German:
chord see street.jpg --prompt "what does the sign say?" \
| chord text --system "translate to German" \
| chord tts | aplay
Summarize a voice memo into a text file:
chord stt memo.wav | chord text --system "summarize in 3 bullets" > notes.md
Hand one model an image and a question spoken out loud, in a single prompt:
chord chat --image diagram.png --audio question.wav "Use the audio question to read the diagram"
Or assemble that multimodal message explicitly, then inspect or send it:
chord pack --image diagram.png --audio question.wav --text "read the diagram" \
| chord chat
Turn a description into an image:
echo "a lighthouse at sunset, oil painting" | chord draw --steps 6 > out.png
Caption a photo, then illustrate the caption:
chord see cat.jpg --prompt "describe this in one vivid sentence" \
| chord draw > reimagined.png
Nothing in chord knows any of these pipelines exist. The shell wrote them.
# Tech stack
- Rust, as a small Cargo workspace.
- Models run through their native engines via Rust bindings:
whisper.cpp (
whisper-rs), llama.cpp (llama-cpp-2, including its vision support), stable-diffusion.cpp (diffusion-rs), Supertonic TTS on ONNX Runtime (ort), and mistral.rs (a pure-Rust engine oncandle) for the multimodalchat. - The shell is the only runtime. There’s no server and no scheduler.
# How it’s built
The core is deliberately tiny:
Kind— the data modalities (text, audio, image, video).Message/Part— the data plane: an ordered list of typed parts, plus the wire codec that frames it (and writes a lone part as bare bytes).Transform— the contract, a functionmessage → message. AUnaryconvenience trait covers the one-in/one-out engines, and a blanket impl lifts everyUnaryinto aTransformfor free, so generalizing the kernel left each mono-modal engine a one-line change.Registryand a self-describingManifest— engines advertise their own kinds and flags, so the host discovers them the waygitfinds subcommands.
Every engine runs out-of-process. Each one is its own small binary — a plain
stdin → stdout filter that links only its own native library. The chord host
links no engine code at all (which is also what keeps mistral.rs’s candle and
llama.cpp’s ggml out of one address space); for each one it spawns the matching
binary and pipes bytes through it:
chord (host)
└─ spawns → chord-stt whisper.cpp
└─ spawns → chord-tts Supertonic / ONNX
└─ spawns → chord-text llama.cpp
└─ spawns → chord-chat mistral.rs (multimodal)
└─ spawns → chord-see llama.cpp (vision)
└─ spawns → chord-draw stable-diffusion.cpp
Code’s on GitHub.