odu: a CI runner for agents and humans · kolu
juspay/odu<br>In the last post I argued that @kolu/surface over ssh makes a new class of app cheap: install-free, ephemeral, typed, and reactive — the source of truth lives on another machine and the plumbing is identical to running locally. drishti, htop for a whole fleet with nothing installed on the remotes, was the flagship. This post is about the second app in that class, and it’s one I use every day: odu , the thing that runs Kolu’s own CI.
odu (Tamil ஓடு — run) is a CI runner. You tag one just recipe as your pipeline, point odu at a couple of machines, and it runs the recipe’s dependency DAG across all of them, posts a GitHub commit status per node, and gives you back a verdict. That part is unremarkable; every team has a tool that does it. What’s different is the shape: odu doesn’t hand you a batch job and a directory of logs. It holds the run as live, typed state you attach to — and because that state is a @kolu/surface, the same run is a terminal dashboard for me and an MCP server for my coding agent, off one definition. It’s local CI built for both of the things that read CI now: humans, and the agents working alongside them.
odu run on the left, odu attach on the right — two terminals reading one live pipeline, the recipes×platforms matrix repainting as nodes go green.
The whole thing stands on four pieces, and naming them up front is the fastest way to say what odu is: oRPC for the transport, @kolu/surface for the typed reactive state, @kolu/surface-nix-host to provision the build machines over ssh with nothing installed, and @kolu/surface-mcp to hand the whole thing to an agent. The rest of this post is what each of those buys.
Why CI should be a live service
A normal local CI tool is a translator. You give it a task graph, it compiles that graph into a batch process — for justci, the tool odu replaced, the compilation target was a process-compose document — runs it once to a terminal verdict, and leaves you log files. If you want to know what’s happening mid-run, you scrape those logs or poll a process supervisor’s socket with a separately-versioned client. The run isn’t something you can talk to; it’s a job that happens and is then over.
I wanted the other thing. An agent — and, honestly, a person — wants to attach to a running system and ask it questions: what nodes exist, which one is red, show me that node’s log, run this one again. Those aren’t batch-job operations. They’re what you’d run against a live service. So I built odu as one: the runner owns the pipeline as state and serves it, live, the entire time it’s up. A run isn’t a process you scrape after the fact. It’s a service you attach to while it’s happening.
That single decision is what makes everything downstream fall out. justci actually tried to grow an agent-facing mode and backed it out, because there was no way to express “the pipeline is registered and idle, nothing running yet” on top of a substrate that starts every recipe the moment it comes up — and no live event source to push a node’s transition as it happened. odu doesn’t hit either wall, and not because it’s cleverer. It’s the other shape. The idle DAG and the live stream aren’t features I added; they’re what “the runner owns the state” already means.
batch translator vs. live service — the same DAG, two shapesbatch translatorgit bundle incompile + run onceeverything at oncescrape .log files outno idle state to attach to;no live source to push fromodu · live serviceDAG sits idleservice is already upattach · runstart nodes explicitlylive snapshot-then-deltas streamidle-vs-running separation is free —attach to a quiet DAG, or a busy one<br>What a run actually does
The reason odu can own the pipeline as state without a server you stand up is that the state is small and the work is remote. A run is a coordinator on your machine driving lanes on other machines over ssh. Concretely:
It refuses to lie. Strict by default: a real run won’t touch a dirty tree. It pins HEAD in a git worktree, and that pinned commit is what gets tested.
It reads your DAG from just. Exactly one recipe carries [metadata("ci")]; odu takes its dependency closure as the pipeline. No second config file describing the graph — the graph is your justfile.
It provisions each platform over ssh. For every machine in hosts.json, the coordinator nix copys the runner derivation over, realises it on the host, and runs odu-runner --stdio. The host then git fetches your pushed commit into a per-SHA workspace and runs each node as just --no-deps . A lane host needs ssh, Nix, and outbound https — nothing else. No agent installed, no port opened, no daemon configured; the runner travels as a Nix closure and the toolchain comes from your repo’s own dev shell. This is @kolu/surface-nix-host doing exactly what it does for drishti.
It fans the lanes into one surface. Every lane’s state merges into a single pipeline surface, served on a unix socket (.ci/odu.sock)...