Published on

chord: composing AI models as Unix filters

Authors
  • avatar
    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 on candle) for the multimodal chat.
  • 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 function message → message. A Unary convenience trait covers the one-in/one-out engines, and a blanket impl lifts every Unary into a Transform for free, so generalizing the kernel left each mono-modal engine a one-line change.
  • Registry and a self-describing Manifest — engines advertise their own kinds and flags, so the host discovers them the way git finds 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.