Your agent does the thinking. Caduceus does the paperwork.
A Hermes plugin that turns a labeled GitHub issue into a pull request, without making you babysit it.
Caduceus is a Unix daemon, shipped as a Hermes plugin, that polls
GitHub for labeled issues, runs your AI harness against them in
isolated worktrees, enforces hard timeouts, and finalizes the
result as branch → push → PR → close. Linux is tier-1; macOS is
tier-2: it compiles, runs, and is enforced by CI (macos / test),
with real process-identity (proc_pidinfo), descendant reaping
(proc_listchildpids), and subreaper semantics on Linux only
(prctl(PR_SET_CHILD_SUBREAPER); macOS relies on process-group
kills plus best-effort descendant enumeration — grandchildren that
setsid away are still reaped via the portable seam). Windows is
not a target. This is not the project for you if that's a problem.
We're opinionated about three things, and the rest of this document will tell you what they are, why, and how to push back when we're wrong:
- Deterministic infrastructure does not live inside the
non-deterministic loop. The daemon owns polling,
claims, worktrees, timeouts, Git, GitHub, retries, and
the public-voice rule. The worker owns "what does the
code say, and what should it say next?" They meet at a
single env-var contract and a single
worker-result.jsonfile. We will not put an LLM call inside our state machine, and we will not put a GitHub API client inside your harness. - Zero inbound networking, no shortcuts around the public-voice rule. The daemon is pull-only, refuses to listen on any port, and refuses to publish a comment or PR body containing a hardcoded list of internal tool names. This is the only moralizing we do in the codebase, and we will defend it.
- The bridge is a file you own. Setup seeds a
reference bridge at
~/.hermes/caduceus/worker-bridge.py. You edit that file. You point it at pi, codex, claude-code, or your own custom harness — Caduceus has no opinion about which one. Plugin source updates will not overwrite your bridge. If the upstream bridge template changes, setup writes a sibling.newcandidate and tells you, instead of clobbering your edits.
If you want a managed hosted product with a web dashboard and a monthly invoice, this is not it. If you want a single Rust binary and a Python script and the ability to read every line of the code that runs on your behalf, welcome.
A note on what this project is for: Caduceus exists to reduce the operator's workload, not to remove the operator from the loop. Every PR Caduceus opens is opened for a human to read and merge. The daemon surfaces state and failures; humans decide what to do about them. We are not building toward a system where a bot ships code unattended while the maintainers sleep. If that is what you want, this is not the project for you either.
┌─────────────────────────────┐
[GitHub]◀───▶│ Caduceus daemon │◀─── `caduceus run`
(outbound │ (Rust · single binary) │ every 2 min,
only) │ · ETag-aware 304 polling │ cron-driven
│ · scheduler leadership │
│ · per-issue claim files │
│ · isolated git worktrees │
│ · hard worker timeout │
│ · public-voice validator │
└──────────────┬──────────────┘
│ sanitized env (no gh creds)
│ bounded transcript pipe
▼
┌─────────────────────────────┐
│ your worker-bridge.py │ ← you own this
│ (the bridge is harness- │ file. edit it.
│ agnostic; ship the │
│ reference or your own) │
└─────────────────────────────┘
The daemon polls, picks one or more issues per tick, claims each
under a per-issue lease (bounded by worker_parallelism),
provisions a worktree, spawns
the bridge as a child of a Rust worker supervisor (not
systemd, not a shell), waits for exit, then finalizes:
commit, push, find-or-create the PR, post the completion
comment, close the issue. Investigation tickets do the
same minus the commit/push/PR.
# Hermes Agent v0.18.2 or newer
hermes plugins install barkley-assistant/caduceus --enable
hermes caduceus setup # build + seed your bridge
hermes caduceus cron-install # 2-min no-agent job
hermes caduceus status # verifyThe install does three things, in order, and is idempotent:
cargo build --release --lockedof the Rust binary.- Atomic install of the binary as
<plugin>/bin/caduceus. - Seed
~/.hermes/caduceus/worker-bridge.py(only if absent; the shipped template lives inplugin-assets/worker-bridge.py).
hermes plugins update caduceus refreshes the source.
Rerun hermes caduceus setup to rebuild. Before
removal, run hermes caduceus cron-remove then hermes plugins remove caduceus; your state, your bridge, and
your config all survive.
If you'd rather not use Hermes, you can run the binary directly. You lose the plugin's skill, slash command, and cron integration, but the daemon is the same:
git clone https://github.com/barkley-assistant/caduceus
cd caduceus
cargo build --release --locked
install -m 0755 target/release/caduceus ~/.local/bin/caduceus
# config at ~/.config/caduceus/config.yaml under `caduceus:`
# see https://github.com/barkley-assistant/caduceus/wiki/Configuration for the full schemaA standalone install requires you set worker_command
explicitly. The daemon will refuse to start without it.
This is on purpose: the Hermes plugin has a default
bridge path; you don't, so the daemon makes you say it
out loud.
Caduceus can dispatch workers inside a container instead
of directly on the host. The whole sandbox lives under
one nested sandbox: section — the single source of
truth for what the OCI executor enforces:
executor_mode: oci
sandbox:
engine: docker # or podman
image: "caduceus-worker@sha256:<64 lowercase hex>" # required, no default
pull_policy: if_missing # never | if_missing | always
resources: { cpus: 2.0, memory_mb: 2048, pids: 256, tmpfs_mb: 256, shm_mb: 64 }
network: none # none | unrestricted (see below; never host)
pass_env: []
stop_timeout_seconds: 10
kill_timeout_seconds: 5
reconcile_timeout_seconds: 60
reserved_host_disk_mb: 2048 # 0 disables the disk-pressure watchdogTrustedHost configs (the default) may omit sandbox:
entirely; executor_mode: oci fails to load without a
valid sandbox.image. The flat prototype keys that
earlier versions of this project used for OCI sandbox
configuration are rejected at load with an unknown-field
error, and there is no migration path — that surface was
never publicly released. The verbatim removal list lives
on the
configuration wiki page.
What the worker container sees is a closed, typed spec:
- An exactly-specified environment. The container receives the
canonical
CADUCEUS_*variable set (run, issue, context, branch, and the container-side worktree/result paths), the two compat valuesHOME=/tmpandTMPDIR=/tmp, and nothing else from the host — no host-environment inheritance. The whole environment travels through ONE daemon-private file (mode 0600, randomly named, under<state_dir>/oci-runs/<run_id>) passed tocreateas a single--env-file; no environment value is ever in the container-create argv, and the file is deleted immediately aftercreatereturns on every path (success, failure, cancellation). Canonical free-text values (such as the issue title and body) are newline-normalized for the line-based env file (\r\nand\ncollapse to a single space), so multi-line GitHub issues run; the full multi-line content still reaches the worker verbatim through the prompt file written into the worktree. Operatorpass_envvalues are NOT normalized: a newline-bearing value fails the run closed, before the container is created. - Operator
sandbox.pass_env(exact names only). Each entry names ONE daemon-environment variable to forward, verbatim — no prefix patterns. A requested name absent from the daemon environment fails the run with a typed error BEFORE the container is created; nothing is silently skipped. Credential names (GITHUB_TOKEN,GH_TOKEN,CADUCEUS_GITHUB_TOKEN,AUTO_ISSUE_GITHUB_TOKEN, any name containing bothGITHUBandTOKEN, and anyCADUCEUS_*name containingSECRETorTOKEN) are refused at config load — whether or not the variable currently exists. Names colliding with the canonicalCADUCEUS_*set orHOME/TMPDIRare also refused at load. - Threat note. Anything handed to a worker — including
pass_envvalues — is readable and exfiltratable by that worker, especially withnetwork: unrestricted.pass_envis for non-sensitive worker inputs (proxy endpoints, feature flags, tool config); never for credentials. A worker with unrestricted network access can send anything it can read to any host. - Two writable host-backed surfaces, nothing else.
/workspacebinds the per-run worktree directly (no copied or.git-stripped second workspace) and/outputis a daemon-owned directory under the daemon state directory (<state_dir>/oci-runs/<run_id>/output), never a sibling of the worktree./tmpand/dev/shmare the only tmpfs, each bounded by the configured sizes. Any other host-backed mount would be a resolution-time typed error, before a container exists. - A daemon-owned
.gitshadow. A worktree's.gitis agitdir:pointer into the main repo's object database, so the container sees a read-only shadow at/workspace/.gitinstead: a harmless sentinel file for a pointer-file.git, an empty read-only directory for a.gitdirectory, and no shadow at all when.gitis absent. The worker can neither read the real gitdir nor write/workspace/.git; repo operations belong to the host-side finalize step. - Dynamic runtime identity. The container runs as the
worktree owner's real UID/GID, probed before container
start — never a hard-coded
1000:1000. Docker rootful renders--user <owner-uid>:<owner-gid>; Docker rootless emits no--user(container root maps to the unprivileged engine user via the rootless user namespace); Podman rootless renders plain--userns keep-idso the in-container identity equals the daemon/worktree owner; Podman rootful follows the rootful rule. Unsupported namespace configurations (the canonical case: a rootful engine with userns-remap, or an engine whose mode cannot be determined) are refused with a typed error before any container is created, andhermes caduceus doctorreports the engine/mode as unavailable.
OCI runs use one crash-safe lifecycle. The container ID is durably recorded
after create and before start; execution is raced against the worker
deadline, daemon cancellation, and disk pressure. Cleanup uses bounded
stop → kill → rm --force and confirms the container is absent before
recording Removed. A fresh daemon token keeps cleanup running even when the
parent run is cancelled, and active runs refresh the normal 5-second
heartbeat.
The OCI run store is schema v7. There is no legacy v6 migration: starting
against a v6 state database stops with a stale-schema error, and operators
must initialise fresh state. Each installation also gets a UUID persisted
atomically by the metadata store. It replaces the state-directory basename
as caduceus.daemon_id, so discovery never touches containers belonging to a
different installation. Restarting the daemon is the recovery operation;
startup reconciliation converges durable rows and labeled engine containers
without manual container cleanup.
Every OCI run on both engines (Docker and Podman) gets the following baseline, emitted by the argv renderer on every single run. There is no config knob, profile, or opt-out that can disable or weaken any of these controls; unknown config fields are rejected at parse time and resource floors prevent zeroing a control to an unsafe value.
--read-only— read-only container rootfs; writes outside the declared surfaces fail EROFS.--cap-drop ALL— no Linux capabilities in-container.--security-opt no-new-privileges— setuid cannot re-escalate.--cpus <resources.cpus>— CPU quota (floor 0.25).--memory <resources.memory_mb>mand--memory-swap <resources.memory_mb>m— the swap limit is pinned EQUAL to the memory limit, so committed memory (RAM + swap) can never exceed the memory bound (no swap rescue). Floor 64 MiB.--pids-limit <resources.pids>— fork bombs die at the limit. Floor 16.- Bounded ephemeral tmpfs:
--tmpfs /tmp:size=<tmpfs_mb>mand--tmpfs /dev/shm:size=<shm_mb>m— the only writable ephemeral surfaces, each floored at 1 MiB (asize=0mwould let the engine apply an unbounded default, silently weakening the baseline). - No devices, no engine/runtime socket mounts
(
docker.sock/podman.sockare denied at resolve time), and no host namespace sharing (--pid host,--ipc host,--uts hostare structurally unrepresentable — the spec has no field for them). - Typed-only networking, two modes:
sandbox.networkisnone(the default) orunrestricted.nonerenders--network none: loopback-only, no outbound connectivity at all.unrestrictedrenders--network bridgeon both engines: the engine's default isolated bridge with full outbound internet access via NAT.unrestrictedis NOT host networking — the container joins no host namespace, and--network hostis structurally unrepresentable (the config rejects anyhosttoken at load with a typed error). - Bounded engine logs:
--log-opt max-size=10mand--log-opt max-file=3on every run (worst case 30 MiB of on-disk engine logs per container). - Bounded daemon-side diagnostic capture: after each run,
the daemon persists
<engine> logsfor the container, capped at 1 MiB (tail truncation with a marker), under<state_dir>/oci-runs/<run_id>/engine.log(mode 0600).
sandbox.reserved_host_disk_mb (default 2048) is a
free-space floor, sampled every 30 s across the DISTINCT
filesystems hosting the daemon state dir, the repo storage
/ worktrees, and the OCI output dirs — deduplicated by
device ID so a shared filesystem is sampled exactly once.
- Breach (any sampled filesystem below the reserve):
in-flight OCI work is terminated via the existing
stop → kill → rm path, and new OCI dispatch is refused
with a typed
OciDiskPressureerror until the reserve recovers. TrustedHost work is not subject to the watchdog. - Recovery hysteresis: after a breach, free space must exceed the reserve by 256 MiB before new work is re-enabled — recovery at exactly the threshold does not re-enable, preventing flapping.
0disables the watchdog entirely (no sampling, no enforcement). The default2048enables it.
Honest limits: this is a host-level mitigation, not a
per-container byte quota. /workspace remains a host
bind mount with NO per-container byte quota — a runaway
run can still consume disk between samples (detection
latency is bounded by the 30 s sampling interval plus the
stop/kill timeouts). The watchdog bounds the damage and
stops the bleeding; it does not isolate storage per run.
Caduceus ships a minimal, deterministic reference worker
image that proves the worker contract end to end:
plugin-assets/worker-reference-image/ (see its
README).
It pins busybox:1.36.1 by SHA256 digest (no package
manager, compiler, or LLM tooling) and contains only the
contract helper (caduceus-env.sh), the result writer
(write-result.sh), the certification probes
(worker-probe sentinel-read|mount-probe|resource-hog| network-probe), and the busybox runtime. The image is a
test fixture and an operator example — never a production
dependency of the executor (an independence test keeps
src/ free of any reference to it).
CI builds the image locally and runs the contract smoke in
the oci-reference-image job of .github/workflows/ci.yml
(no push). Publication happens only from the
release-worker-image workflow on v* tags, which pushes
ghcr.io/barkley-assistant/caduceus-worker-reference: vX.Y.Z and latest with provenance and echoes the
published digest into the workflow summary and release
notes.
Operator example — run it exactly like the executor does:
read-only rootfs, /workspace and /output bind mounts, a
bounded /tmp tmpfs, the canonical environment, and
arbitrary --entrypoint argv:
ws="$(mktemp -d)" && out="$(mktemp -d)"
echo "example-sentinel" > "$ws/sentinel.txt"
docker run --rm --read-only --network none --tmpfs /tmp:size=256m \
-v "$ws":/workspace:rw -v "$out":/output:rw \
-e CADUCEUS_RUN_ID=example \
-e CADUCEUS_ISSUE_ID=example \
-e CADUCEUS_ISSUE_NUMBER=1 \
-e CADUCEUS_ISSUE_REPO=owner/repo \
-e CADUCEUS_ISSUE_TITLE="Example run" \
-e CADUCEUS_ISSUE_BODY="Example body" \
-e 'CADUCEUS_ISSUE_LABELS_JSON=["example"]' \
-e 'CADUCEUS_CONTEXT_JSON={}' \
-e CADUCEUS_BRANCH_NAME=main \
-e CADUCEUS_WORKTREE_PATH=/workspace \
-e CADUCEUS_RESULT_PATH=/output/worker-result.json \
--entrypoint /bin/sh \
caduceus-worker-reference:local -c '
/usr/local/bin/caduceus-env.sh --names-only &&
/usr/local/bin/worker-probe sentinel-read &&
/usr/local/bin/write-result.sh &&
cat /output/worker-result.json
'OCI tests that need a real container use the unrelated
fixture image (tests/fixtures/oci-fixture-image/), never
the reference image; the fixture-parity test enforces the
separation.
-
git clone,cargo build,hermes caduceus setup(or the standalone equivalent above). -
Put your watched repos at
~/projects/<owner>/<repo>with non-interactive Git credentials (SSH key or credential helper). -
Create the two labels in each repo:
gh label create "🤖 auto-fix" --repo OWNER/REPO --color 7C3AED \ --description "Triggers Caduceus code automation" gh label create "🤖 auto-fix-investigate" --repo OWNER/REPO \ --color 7C3AED --description "Triggers Caduceus investigation summary"
-
Drop the label on an issue. Wait two minutes. Watch
caduceus status. When the daemon picks it up, the bridge runs and you get a PR. -
First time, run with
CADUCEUS_DRY_RUN=1. Dry-run does everything except commit / push / comment / label-mutate / PR / close. It writes a<run_id>.dry-run.mdreport under<state_dir>/runs/. You should be reading that report before the first real run. Trust, but verify.
You will not get far without these. The full schema lives in configuration and the wiring lives in Home; this is the short version with the opinions attached.
watched_repos— the list ofowner/repopairs the daemon polls. Each entry must resolve to a local clone underworkdir_base/<owner>/<repo>(default~/projects/<owner>/<repo>) with a workingoriginremote before the daemon will pick up an issue. The daemon refuses to poll awatched_reposentry that has no local clone. This is not a courtesy — a daemon that quietly retried GitHub forever against a missing clone is how you burn through a rate limit at 3 a.m. and never know why.worker_command— the path the daemon execs after a tick. The Hermes plugin seeds a default at~/.hermes/caduceus/worker-bridge.py; a standalone install requires this field to be set explicitly. The daemon refuses to start without it on a standalone install, and that is the right default: a daemon that silently invents a worker path is a daemon that will surprise you on the one host where the convention does not hold.poll_interval_seconds— how often the cron tick fires. Default is120. The plugin installs a 2-minute cron job; the operator can override per environment. Lower it if you want; do not set it to zero and expect a polite daemon.ticket_label_code— the GitHub label that triggers a code-fixing run (default🤖 auto-fix). The investigation label isticket_label_investigation(default🤖 auto-fix-investigation). The two labels are created in step 3 of the 60-second orientation above.
Everything else lives in configuration. If a config key is not named there, it is not part of the public contract surface; the daemon ignores it, which is the honest answer to "why does my custom key do nothing?"
Moved out of the README on purpose. The README is the front door; the manual is in the wiki:
- installation — Hermes vs standalone, prerequisites, Hermes plugin lifecycle (install / update / remove), the cron contract, and the supported-host tier table.
- configuration — every config field, defaults, resolution order, environment variables.
- the-bridge — the
worker-bridge.pycontract, theCADUCEUS_*env vars, theworker-result.jsonschema, how to plug in a different harness. - state-recovery —
corrupt state, stuck issues, the
migrate-statecommand, backup retention. - troubleshooting — the common failure modes with the actual error text and the actual fix.
- faq — short.
Each worker run produces one bounded transcript file at
<state-dir>/runs/<run-id>.log. The supervisor captures
both the worker's stdout and stderr into it,
byte-interleaved without stream markers, up to
transcript_max_bytes; output past the cap is dropped
behind a truncation marker line. See
configuration
for the limit and retention knobs.
JSON is the default state backend; SQLite is opt-in. If
your state directory contains a JSON state file and you
want the SQLite backend (optional), use the migrate-state
command to import existing entries:
caduceus migrate-state --from <path-to-legacy.json> [--dry-run]
caduceus migrate-state --to-sqlite [--dry-run]
Do not edit daemon state, metadata, claim files, or transcripts by hand. Caduceus owns those files. Use supported commands so it can take its lock, validate input, and install changes atomically.
- Read the release notes for the version you are installing. They identify the supported source formats, any required commands, and version-specific limitations.
- Record your active configuration and the resolved state directory path.
- Stop scheduled ticks and any automation that may be polling the same issues. Wait for any active tick to finish before proceeding.
- Confirm that GitHub and Git credentials are available to the account that will run the daemon after the upgrade.
The flow in this section is the --from JSON importer.
Run a dry run first:
caduceus migrate-state --from /path/to/legacy.json --dry-run
Compare the reported import and skip counts with the source data. If they are not what you expect, stop and resolve the discrepancy before applying.
When ready:
caduceus migrate-state --from /path/to/legacy.json
The importer takes the daemon lock, validates every record, and adds entries that are not already present in live state. It does not overwrite conflicting entries. Malformed input leaves live state unchanged. A successful write uses the normal atomic-write procedure and creates a timestamped backup in the state directory.
Running the same import again is idempotent: already-present entries are reported as skipped and are not duplicated.
To switch to the SQLite backend instead, run
caduceus migrate-state --to-sqlite. It imports the JSON
queue into the SQLite store and flips state_backend to
sqlite in the operator's config; validate it the same way
afterwards.
- Run
caduceus statusand review the reported state. - Confirm the expected backup exists in the state directory.
- Run one tick against a test repository and verify its logs, GitHub access, Git credentials, and worker result.
- Re-enable scheduling only after the test tick succeeds.
- Monitor the first scheduled run and retain backups through that observation period.
If the installation includes the Hermes plugin, also run
hermes caduceus doctor after setup or an upgrade. A
missing scheduler capability, required gateway restart,
incomplete configuration, or unavailable provider must be
treated as an actionable setup failure rather than a
healthy installation.
If validation fails, stop scheduling before changing
state. The --from import command preserves prior content
as <state_dir>/state.json.bak-<timestamp>. A typical
rollback:
# Stop the Caduceus scheduler first.
cp <state_dir>/state.json.bak-<timestamp> <state_dir>/state.json
# Restart the known-good installation.
Rollback after --to-sqlite is config-side: set
state_backend back to json and restart; the JSON file
is preserved alongside the SQLite store.
Use this only while the daemon is stopped. When Caduceus
detects malformed state, it preserves the rejected bytes
as a timestamped state.json.corrupt-* archive and
refuses to proceed. Do not edit that archive or the live
state in place. Follow the supported recovery process in
state recovery.
Use the queue command to retry a failed item:
caduceus status
caduceus queue reset owner/repo#number --dry-run
caduceus queue reset owner/repo#number
The normal reset keeps the saved finalization checkpoint
so a later tick can resume safely. --force-finalization-reset
discards that checkpoint after warning about the affected
branch and pull request; it never deletes remote branches
or pull requests.
For Hermes installations, remove scheduling before removing the plugin:
hermes caduceus cron-remove
hermes plugins remove caduceus
This preserves the state directory, user-owned bridge,
configuration, watched repositories, and worktrees for
inspection or a later reinstall. Run caduceus worktree-gc
when it is safe to clean unused worktrees.
Read this before you install it. We mean it.
- Not a multi-host system. Caduceus is one daemon per host. If you run two daemons on two machines, they will both poll the same org and step on each other. The result is not "two workers in parallel"; it is "two workers racing for the same issue, one of them loses, the issue gets retried twice." Multi-host state with proper leader election is a future conversation, and we are not going to ship a half-baked version of it because you asked nicely.
- Not a GitHub App. Caduceus uses a fine-grained PAT. GitHub App authentication with installation tokens is a future feature. We know ops teams have asked and the rotation story is better with App auth; we are not shipping it now because the migration story for operators on PAT is more important than the migration story for hypothetical future operators on App auth.
- Not a managed hosted service. We don't run your automation. You do. There is no web dashboard, no monthly invoice, no Slack integration that pings us. The binary is yours, the daemon logs to your disk, and your credentials never leave your machine. If you want a hosted alternative, several exist; we are not them.
- Not "OpenCode inside the daemon". The daemon has absolutely no opinion about which LLM you call. We ship a reference bridge because every project needs a starting point; the bridge currently calls OpenCode because that's what we use internally. Swap the bridge for pi, codex, claude-code, or your own script, and the daemon will not notice or care. See the-bridge for the contract.
- Not a replacement for code review. Every PR that Caduceus opens is opened for a human to review and merge. There is no auto-merge today. Policy-gated auto-merge with a documented policy in plain English is a future feature, not a current one.
- Not a webhook receiver. The daemon is pull-only. It polls GitHub on a schedule. We will never accept inbound HTTP. If you want push semantics, write a webhook → label-relabel shim in front of Caduceus; that's your shim, not ours.
- Not a queue you can attach a custom worker to.
The worker contract is
worker-bridge.pyplus theCADUCEUS_*env vars plus theworker-result.jsonfile. That's it. If you want to bypass that contract, you don't want Caduceus; you want a job queue.
This project follows Semantic Versioning 2.0.0.
The public surface — caduceus CLI, the Config YAML
schema, the plugin manifest fields, the
worker-bridge.py env-var contract, the state file
format, the default comment_forbidden_strings — is
versioned; everything else is implementation detail and
can change between minor releases.
CONTRIBUTING.md— how to file issues, open PRs, what the CI expects, the commit format we use.RELEASING.md— SemVer policy, what counts as a breaking change, the release cadence (or lack of one), how release tags are cut and what CI runs on them.CHANGELOG.md— keep-a-changelog format. Every user-visible change lands an entry.AGENTS.md— agent guidance for both human contributors and AI tools. Read it before opening a PR; the constraints on state files, the contract-revision procedure, the test discipline, and the no-edits-to-published-prompts rule live there.
MIT. See LICENSE.
