odu
SkillDev toolsReference for the `odu` runner, how to invoke a full pipeline, a single recipe, or a platform-pinned node, and how to attach to a live run, from a project whose CI odu runs. Trigger when the user asks to "run CI", "run the pipeline", "re-run a check", to run named lanes or recipes (e.g. "run fmt an
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the odu skill
What this skill tells your AI
The instructions your AI receives, as published by juspay/kolu in .agents/skills/odu/SKILL.md and read by ahel’s review.
odu (Tamil ஓடு — "run") runs a repository's
just recipe DAG tagged [metadata("ci")] across machines, posts a GitHub
commit status per <recipe>@<platform>, and keeps every run in a per-user
catalog.
There is one shared service and one vocabulary. A per-user singleton
(http://127.0.0.1:18440) owns every run. Three faces project the same
fourteen verbs — and none of them has a verb of its own:
run_start · run_wait · run_read · run_list · run_retry · run_cancel · log_read
catalog_import · catalog_prune · pipeline_read
venue_probe · venue_hold · venue_release · protect_apply
| Face | Spelling | Who uses it |
|---|---|---|
| terminal | odu surface <verb> --input '{…}' --json | you, with shell access |
| agent | MCP tools of the same names | you, in an MCP host |
| browser | odu web | the human |
The two spellings below are the same call. Use whichever face you have; never mix vocabularies, and never reach for a face-specific workaround.
A request to run CI is a request to run
odu— neverjust ci. Many consuming repos expose ajust ci(orjust <recipe>) that runs a pipeline locally. Do not shell out to it: it bypasses everything odu gives you — the durable run record, per-node GitHub statuses, structured failures with addressed evidence, fail-fast, retry and cancel. "run CI", "run fmt and nix", "re-run the e2e lane" all mean drive an odu run through the verbs below.
Run keys are host-global. Every address is a run id or a key built from one
— never a path relative to whoever is calling. Your cwd is not a fact about
what the user meant, so run_start takes the checkout as an explicit absolute
path, and run_start is the only verb a filesystem path appears in at all.
The face holds no run authority. Every call goes over the wire to the singleton. A harness restarting your MCP server kills nothing, two agents are two clients of one truth, and the run outlives whoever started it.
The loop
1. bootstrap — a face, once, unpinned from upstream
2. run_start — absolute checkout + expectedSha + YOUR requestId
3. run_wait — bounded; feed the returned cursor back as `after`
4. log_read — on a failure's logKey, echoed verbatim
5. run_retry — same commit; a NEW commit is a new run_start
6. verify — scope, sha, contentSha, reportingDebt, before you say "green"
1. Bootstrap
Nix is the only supported way to run odu, and the reference is unpinned
upstream. --accept-flake-config is not optional for an agent: odu's flake
declares a binary cache, and without the flag nix run stops on an interactive
trust prompt your tool call cannot answer.
nix run --accept-flake-config github:juspay/odu -- web --background
Every face bootstraps for you: odu surface … and the MCP bridge dial the
singleton and, at the default origin only, start one and verify it is ready
before issuing your call. So in practice you just issue the verb. Run web --background explicitly when you want the service up before the first verb, or
want its URL to hand the human.
- Bare
odu webserves in this terminal until Ctrl-C. Never run it from a tool call — it will block until your timeout.--backgroundis the agent's spelling. - A face never recovers a failed dial by executing locally. If bootstrap fails you get exit 3 and a reason, not a silent local run.
- An
--originnaming somewhere OTHER than your own service is dialled and only dialled — a typo reports "nothing is serving there" rather than spawning a daemon that could not bind that address anyway. Your own origin still bootstraps, whether it is the default or one$ODU_WEB_ORIGINmoved.
2. Start
odu surface run_start --input '{"checkout":"/abs/path/to/repo","expectedSha":"'"$SHA"'","requestId":"fix-lint-1"}' --json
// MCP tool: run_start
{ "checkout": "/abs/path/to/repo", "expectedSha": "<sha>", "requestId": "fix-lint-1" }
// optional: selectors[], platforms[], hostPins[], root, noDeps,
// noStrict, noSnapshot, noPost, supersede
// hostsFile is a terminal's own $ODU_HOSTS. Omit it — you have no shell,
// and omitting it keeps the service's configured inventory.
selectors are recipe[@platform] — ["ci::e2e"], ["fmt","nix"],
["ci::unit@x86_64-linux"]. Empty means the whole [metadata("ci")] DAG on
every configured platform. platforms slices the fanout; noDeps runs only
the named nodes.
checkoutis absolute and explicit. Read it from the repo you are working in; do not pass a relative path and do not assume the service shares your cwd.expectedShais a hard check. A checkout that has moved on is refused (checkout_refused), never quietly a different run.requestIdis mandatory, and that is the feature. See below.- A checkout that already has a live run does not get a second one, and this
is an ANSWER rather than a refusal: you get
accepted: falsewithexistingnaming the run that is already there. Observe it, or repeat the call withsupersede: true— which cancels the WHOLE live run in that checkout.
The receipt carries runId, requestId, sha, scope, a usually-null
endpoint (the coordinator has not bound its socket yet), and a cursor
positioned at the run's beginning — pass that cursor straight into your first
run_wait so you resume rather than replay.
Lost replies are why requestId exists. Mint one id per intent, never per
attempt. If a reply is lost — a timeout, a killed tool call, a restarted harness
— repeat the call with the same id and the same input: you get the recorded
receipt back with replayed: true, and no second run. A fresh id is a licence
to run twice; never mint one to "retry a call". Two rules follow:
request_conflict— that id was used for a different input. Pick a new id for the new intent, or resend the original input.request_unresolved— the request was accepted and its outcome is genuinely unknown. Do not re-issue it with a new id. Look for the run (odu surface keys runs/get runs, or the board) and reconcile from what is actually there.
3. Wait — bounded, resumable, fail-fast
odu surface run_wait --input '{"runId":"'"$RUN"'","after":"'"$CURSOR"'","deadlineMs":120000}' --json
// MCP tool: run_wait
{ "runId": "<run>", "after": "<cursor>", "deadlineMs": 120000 }
// optional: settle (wait for the whole run), limit (page size)
reason is what you branch on:
reason | Meaning | Next move |
|---|---|---|
failure | A red node whose evidence is ready. A normal result, not an error. | Read failures[].excerpt, then log_read its logKey. Start fixing now. |
still_running | The deadline passed with nothing red. | Ask again with the returned cursor as after. |
settled | The whole run is done. | Verify (step 6). |
owner_lost | The coordinator is provably gone without finalizing. | Start a fresh run. |
Fail-fast is the point. run_wait returns on the first actionable red —
a failure whose log has had its last word — while the slow lanes keep running.
failures[] is a floor, not the final tally: more lanes may still go red. Do
not sit through the remaining lanes to "see the full status", and do not pad
deadlineMs and block. The loop is wait → fix → wait again.
passed: true is only trustworthy with settled: true. Never infer green
from a bounded wait that saw nothing red.
Feed the cursor back. Every answer carries a cursor; pass it as after
next time and you are not shown the same events twice. It suppresses repeats and
resolves nothing — a red node you already saw is still red. A cursor from
another run is refused (bad_cursor) with a resync route rather than silently
restarted; that bites hardest after a retry that relaunched (step 5).
Terminal handling: Ctrl-C ends an OBSERVATION, not the run. An interrupted
or disconnected wait exits 130 and the run carries on — as does a wait that
simply hit its deadline. Re-attach with run_wait and your last cursor.
Stopping work is an explicit act: run_cancel.
4. Diagnose — addressed evidence
odu surface log_read --input '{"key":"'"$LOG_KEY"'","offset":-4096}' --json
// MCP tool: log_read
{ "key": "<logKey from failures[]>", "offset": -4096 } // negative offset = tail; limit pages
- Echo the
logKeyverbatim. A run id, a node and an attempt travel as one token precisely so no caller reassembles them from parts. Never build a path. complete: falsemeans the producer's last word is missing — the evidence is truncated, not "the recipe was quiet". Say so rather than concluding from it.excerptSource: "none"on a failure means the log was unreadable. That is never a pass and never "flaky".- Page forward with
nextOffset;eofis about this read,completeis about the log. log_readalso FOLLOWS. PasswaitMsand it holds until the log grows past the end of this page, the attempt finishes, or the deadline passes. FeednextOffsetback asoffset— that is the cursor, and YOU hold it, so a call that dies is re-issued rather than resumed. Stop whenopenis false. Asizesmaller than the offset you asked for means that attempt was re-run and its log rewritten: start again from 0.odu logs -f/--wait-msis the same follow from a terminal.- Watching a live node instead of reading evidence? Subscribe to the
logTailsresource —surface://collections/logTails/<key>as MCP, orodu surface get logTails "$KEY" --followas ndjson. (watchis not mounted onlogTails: it carries no delta verb, because its key set is whatever happens to be subscribed rather than a set of runs.) Evidence for a verdict is alwayslog_read.
5. Retry the same commit — or start the new one
odu surface run_retry --input '{"runId":"'"$RUN"'","selector":"ci::unit@x86_64-linux","requestId":"retry-unit-1"}' --json
// MCP tool: run_retry
{ "runId": "<run>", "selector": "ci::unit@x86_64-linux", "requestId": "retry-unit-1" }
// optional guard: expectAttempt { node, attempt }
selector is <recipe>@<platform>, @<platform>, or a bare recipe name.
- Retrying is not your choice to make.
run_retryresets nodes on a live coordinator when there is one and starts a linked replay run when there is not, and tells you which inmode(live|relaunched). - Watch
effectiveRun, not the run you asked about, and use the receipt'scursor. Arelaunchedretry is a NEW run; your old cursor belongs to its parent and will be refused. - Siblings are preserved.
rootsare the nodes actually reset andresetDependantstheir consequence; every other lane keeps running, keeps its venue and keeps its statuses. expectAttemptrefuses (stale_attempt) if the node moved past the attempt you read. Use it when acting on a reading you took a while ago.requestIdis mandatory here too, with exactly the semantics of step 2.
Retry vs. supersede — the rule that costs the most to get wrong.
| Situation | Verb | What it does |
|---|---|---|
| One lane failed; same commit | run_retry | Re-runs that selector (and its dependants). Cancels nothing. |
| A new commit fixes it | run_start on the new sha, supersede: true if a run is live in that checkout | Replaces the WHOLE run, every lane. |
A new commit is a new run — never a retry, which replays recorded inputs with the old commit pinned. And superseding to retry a flaky lane throws away the darwin lane that was still running and green: the expensive operation for a job the cheap one does.
Cancelling is run_cancel with an explicit scope — {"kind":"run"},
{"kind":"node","node":"ci::fmt@x86_64-linux"} or
{"kind":"lane","platform":"aarch64-darwin"} — plus a requestId. The answer
echoes what was actually cancelled; effective: "nothing" with a detail means
nothing was, and is not a cheerful ok.
6. Verify before you call CI green
Three checks, every time, from the wait answer or the board row:
scope— a selection is not a pipeline.{selectors, platforms, root?, noDeps}. A green over three recipes is a green over three recipes. Only an emptyselectorsand emptyplatforms(and nonoDeps) is "CI is green". Say what you actually ran.shaandcontentSha— are these the inputs you meant? Compare the base against the commit you asked for. Adirtyrun on the board is a verdict about a working tree, not about a commit.reportingDebt— statuses that did not land. Debt never blocks settle (the test verdict is the truth), but an unwritten required context is what blocks a merge. A green run with debt is not a green PR; report it.
And say passed only from settled: true.
Verb reference
| Verb | argv | MCP tool | Input | Answers |
|---|---|---|---|---|
| start | odu surface run_start --input '{…}' --json | run_start | checkout, expectedSha, requestId, selectors?, platforms?, hostPins?, hostsFile? (a terminal's own $ODU_HOSTS; agents omit it), root?, noDeps?, noStrict?, noSnapshot?, noPost?, supersede? | accepted, runId, replayed, sha, scope, endpoint, cursor, existing? |
| wait | odu surface run_wait --input '{…}' --json | run_wait | runId, after?, deadlineMs? (30s default), settle?, limit? | reason, settled, passed, outcome, failures[], failuresTotal, cursor, remaining, reportingDebt[], scope, sha, contentSha?, dirty? |
| diagnose | odu surface log_read --input '{…}' --json | log_read | key, offset? (negative = tail), limit?, waitMs? (follow) | text, offset, size, nextOffset, eof, complete, open |
| retry | odu surface run_retry --input '{…}' --json | run_retry | runId, selector, requestId, expectAttempt? | mode, effectiveRun, parentRun, roots[], resetDependants[], scope, sha, cursor |
| cancel | odu surface run_cancel --input '{…}' --json | run_cancel | runId, scope, requestId | effective, detail |
| read | odu surface run_read --input '{…}' --json | run_read | runId, after?, limit? | run_wait's answer, without the waiting |
Beyond one run
The same vocabulary reaches everything else odu does. These used to be local commands that each did their own work in your process — which meant an agent and a browser simply could not do them at all.
| Verb | argv | MCP tool | Input | Answers |
|---|---|---|---|---|
| find runs | odu surface run_list --input '{…}' --json | run_list | checkout? (absolute), sha? (≥7 hex prefix), seq? (needs sha), limit? | rows[] (newest first), total — the board filtered by the service, in one call |
| resolve a pipeline | odu surface pipeline_read --input '{…}' --json | pipeline_read | checkout, root? | checkout, name, tasks[], mermaid — the DAG, without running it |
| list machines | odu surface venue_probe --input '{}' --json | venue_probe | (none) | source, warnings[], rows[] — the lanes and who holds them |
| hold a machine | odu surface venue_hold --input '{…}' --json | venue_hold | checkout, platforms?, hostsFile?, noWait?, requestId | results[] (held / waiting / already), replayed |
| release it | odu surface venue_release --input '{…}' --json | venue_release | checkout, platforms?, hostsFile?, requestId | released[] (effective: released / nothing), replayed |
| import old runs | odu surface catalog_import --input '{…}' --json | catalog_import | checkout, dryRun?, requestId | imported[], skipped[], catalog |
| expire old runs | odu surface catalog_prune --input '{…}' --json | catalog_prune | retentionDays?, dryRun?, requestId | expired[], kept[], retentionDays, dryRun, replayed |
| require odu's checks | odu surface protect_apply --input '{…}' --json | protect_apply | checkout, branch?, platforms?, hostsFile?, dryRun?, create?, requestId | repo, branch, contexts[], rulesetId, applied, created, derivedFrom, detail, replayed |
requestIdis an idempotency key on these three too. A repeat with the same id replays the recorded answer and performs nothing — which is what stops a lost reply's retry from releasing a hold somebody else has taken since, or overwriting a ruleset edited in between. A repeat with the same id and DIFFERENT input isrequest_conflict; one whose first attempt's outcome is unrecorded isrequest_unresolved, and the answer to that is to read the current state, never to re-issue under a fresh id.replayedsays which you got.- A hold outlives your session.
venue_holdrecords the lease against a checkout, and the holder is the service's child rather than yours — so it survives your process ending, and you mustvenue_releaseit.noWaitgives youwaitinginstead of a queue. protect_applywrites to GitHub. Run it withdryRun: truefirst and readcontexts— that is exactly the set odu will require.derivedFrombeing non-null means the platform set came from this machine's hosts file rather than from you; a repository's required checks should not depend on whose laptop ran the command, so nameplatformsexplicitly when it matters.create: trueis needed to make a ruleset that does not exist yet, because creating protection nobody asked for is not a recovery. Writing needs aghthe serving process can authenticate with; without one you getno_credential, which is fixable rather than fatal —dryRunstill answers.catalog_prunenever expires a run with a live owner — it reports it inkeptwith a reason instead.
Reading state without a verb — the same three resources on both faces:
odu surface keys runs # the board: every registered run
odu surface get runs "$RUN" # one row (state, sha, scope, passed, reportingDebt, cursor)
odu surface get service # who is serving, which build, is it ready
odu surface watch runs # follow the board
odu surface --help # the whole projection
odu history list [--all] [--limit N] [-o json] # the catalog from a terminal
MCP resources: surface://cells/service, surface://collections/runs,
surface://collections/logTails/{key}.
odu run is the human's one-shot spelling of the same thing: it calls
run_start for the checkout you are standing in and then observes. Its options
are run_start's inputs plus four of its own — --no-wait (start and return
without observing), --request-id, --origin and -o json. Ctrl-C stops
observing; the run keeps going.
Refusals, and what to do about them
A refusal is odu declining the request — not CI failing, and not a transport
error. code is what you branch on; message is for the human; resync and
suggestion carry the recovery where there is one.
code | Next move |
|---|---|
bad_input | Fix the input; it could not have meant anything. |
unknown_run / expired | The run is not in the catalog (or aged out). Find it on the board, or start a fresh run. |
bad_cursor | Run the resync it carries. Usually a cursor from a parent run after a relaunched retry. |
checkout_refused | Not a git repo, or the checkout moved off expectedSha. Re-read HEAD and re-issue. |
not_replayable | Dirty live tree, or the checkout is gone. Start a new run instead of retrying. |
request_conflict | Same id, different input. New intent ⇒ new id. |
request_unresolved | Outcome unknown. Do not re-issue with a new id — find the run and reconcile. |
stale_attempt | The node moved past your expectAttempt. Re-read, then decide again. |
launch_failed | The service could not start the coordinator; the message says why. |
pipeline_refused | The checkout's justfile could not be resolved into a DAG; the message says what broke. |
no_venue | No lane matched — the hosts file configures none for that platform. Read venue_probe. |
no_credential | The serving process has no usable gh. Authenticate it, or use dryRun. |
Exits — two vocabularies, and 1 means opposite things
odu surface exits are about the CALL, not about CI:
| Exit | Meaning |
|---|---|
| 0 | Answered — including an answer that reports red CI. |
| 1 | odu declared a refusal (one JSON line on stderr, with a code). |
| 2 | Usage error; the call never left the process. |
| 3 | Nothing serving (and, at the default origin, odu tried to start it and says why). |
| 130 | Interrupted — the observation ended, the run carries on. |
odu run / odu wait exits are about CI, because that is what they answer:
| Exit | Meaning |
|---|---|
| 0 | Settled, and it passed. |
| 1 | There is a failure to act on. Not a refusal — red CI. |
| 2 | Still going, nothing red yet. Ask again with the returned cursor. |
| 3 | Its coordinator is gone and it never finalized. Start a fresh run. |
| 4 | No such run, or its evidence expired. |
| 5 | The request itself was refused. |
Read that difference carefully before you branch on a number: exit 1 from
odu surface is odu refusing you, and exit 1 from odu run is your tests
failing. An agent that conflates them reports a broken test as a broken tool.
Wiring the MCP face
The launcher ships beside this skill at serve, installed as
.agents/skills/odu/serve. It is one line — unpinned upstream, over stdio:
exec nix run --accept-flake-config github:juspay/odu -- mcp "$@"
.mcp.json (Claude Code; the same command for Codex / opencode / Gemini CLI):
{ "mcpServers": { "odu": { "type": "stdio", "command": ".claude/skills/odu/serve" } } }
It is deliberately not under a bin/ directory: apm deploys a skill's whole
directory tree but skips a top-level bin/ whenever stdout is not a terminal,
which is every CI install — so a launcher placed there would be named in your
.mcp.json and never actually written.
The bridge dials the singleton, bootstraps it if nothing is serving, and projects the fourteen verbs and three resources. It starts no coordinator and holds no run authority, so a harness restarting it kills nothing.
Nothing stays local
Every public command goes through the service, including odu dump and
odu graph — both are pipeline_read, and an agent can call that verb
directly. They used to be listed here as a deliberate exception on the grounds
that a justfile read touches no run. That was wrong: what odu will run for a
checkout is a question run_start answers through the same engine, so a face
answering it locally is a SECOND RESOLVER of the one thing you most need to be
able to trust — and it could disagree with the run it is meant to predict.
Hosts
Lanes need machines. $ODU_HOSTS (a file path) → ~/.config/odu/hosts.json:
{ "x86_64-linux": ["ci-1", "ci-2"], "aarch64-darwin": "me@mac-mini.local" }
Keys are Nix system tuples; values are anything ssh dials, a list of them (a
pool), or localhost. A run that resolves zero lanes is refused, never
defaulted to localhost. hostPins ("P=ADDR") pins one box for one run;
venue_probe shows the inventory from any face; odu hosts [platform…] is its
terminal spelling.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 79
- Forks
- 12
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
odu- Source
- github.com/juspay/kolu