An agent harness you can leave somewhere. It looks after itself.
Install it on a client’s Mac mini, walk away, and watch it from a distance. Bothy wakes on signed webhooks, cron, chat or polling, runs Codex under supervision, can keep notes of its own on the machine, can record in Cairn, the family’s task ledger, if you run it, and speaks up when it needs you.
A bothy is the unlocked hut in the Scottish hills, left for whoever arrives. Nobody staffs it, and it is still standing when they leave. There is a lamp by the door and a logbook on the table.
Fig. 1Written down before it is answered, killed as a group before it is recorded
The logbook: bothy audit
0416 2026-09-24T10:14:02.118734+00:00 wake received ok pr:42 -0417 2026-09-24T10:14:02.391650+00:00 run admitted ok pr:42 run_20260924T101402Z_8d41e7b20418 2026-09-24T10:14:03.027114+00:00 run started ok pr:42 run_20260924T101402Z_8d41e7b20419 2026-09-24T10:15:07.302114+00:00 run question pending pr:42 run_20260924T101402Z_8d41e7b20420 2026-09-24T10:15:41.772301+00:00 run finished ok pr:42 run_20260924T101402Z_8d41e7b2
0416 2026-09-24T10:14:02.118734+00:00 wake received ok pr:42 -0417 2026-09-24T10:14:02.391650+00:00 run admitted ok pr:42 run_20260924T101402Z_8d41e7b20418 2026-09-24T10:14:03.027114+00:00 run started ok pr:42 run_20260924T101402Z_8d41e7b20419 2026-09-24T10:15:07.302114+00:00 run question pending pr:42 run_20260924T101402Z_8d41e7b20420 2026-09-24T10:15:41.772301+00:00 run finished ok pr:42 run_20260924T101402Z_8d41e7b2
04162026-09-24T10:14:02.118734+00:00 wake received ok pr:42 -
04172026-09-24T10:14:02.391650+00:00 run admitted ok pr:42 run_20260924T101402Z_8d41e7b2
04182026-09-24T10:14:03.027114+00:00 run started ok pr:42 run_20260924T101402Z_8d41e7b2
04192026-09-24T10:15:07.302114+00:00 run question pending pr:42 run_20260924T101402Z_8d41e7b2
04202026-09-24T10:15:41.772301+00:00 run finished ok pr:42 run_20260924T101402Z_8d41e7b2
Then the record: the audit log always; Cairn when the run carries a --ref or its profile grants bothy_note; Slack or Discord only when a person is needed, as they were here, for a question.
No. 02left on a machine
Closed to everything but the tailnet, unless you open one door.
The tailnet door sits on a port Funnel is not allowed to touch. A public route gets its own listener, because Funnel is per port, not per path, and there the signature is the whole of the security. Chat and polling go out from the machine, and so does cron, which needs nothing at all. A delivery that arrives twice is answered 200 and not queued again: the sender did nothing wrong.
Fig. 2Only the doors you open accept connections; chat and polling reach out
tailnetacme-mini:8787webhook via Funnelreplay → 200slack · discordpolling0 inbound
Drawn on the Mac mini it was built for. See Status for what has run, and where.
No. 03one gate
Five runs each see $40 of $50. Three start. Two are refused, and told why.
Measured after the fact, all five would start, and the day could end at $55. So a run reserves its worst case before it begins, and reservations count against the cap. The refusal names the gate and every number that tripped it. A refusal is an event, recorded like any other, never an error.
Fig. 3Reservations count against the cap, so the fourth run is refused
headroom10.007.004.001.0050.00used 40.00r1r2r3r4 and r5 would reach 52.00: refused
headroom10.007.004.001.0050.00used 40.00r1–r3r4 and r5 would reach 52.00: refused
$ bothy run "…" --subject r4refused run_20260924T140311Z_5be0c9d4 0.0000 usd refused at gate 'budget': would reach 52.00 usd against a 50.00 ceiling (40.00 used, 9.00 already reserved, 3.00 requested)
$ bothy run "…" --subject r4refused run_20260924T140311Z_5be0c9d4 0.0000 usd refused at gate 'budget': would reach 52.00 usd against a 50.00 ceiling (40.00 used, 9.00 already reserved, 3.00 requested)
While a run is in flight it holds its whole reservation, and its metered spend fills it from inside. When it ends it settles to what it actually cost and gives the rest back. A crashed run’s reservation expires on a timer. Each job can also carry a token limit and a wall-clock limit (--max-tokens, --wall-clock), and bothy cancel RUN interrupts a live run.
Fig. 4A run settles to what it cost and gives the rest back
reserved 3.00returned 2.59settled 0.41
reserved 3.00returned 2.59settled 0.41
No. 04a day on site
A day on site, as it reads in the book.
An illustrative day at one client. The times are made up; every line below is in the form Bothy prints it.
Fig. 5 Every event of the day leaves one numbered entry
00:0006:0012:0018:00
No. 041203:00
The 03:00 heartbeat finds nothing
A cron job fires on the clock, carries the standing checklist, and is told that if nothing needs attention it should reply NO_REPLY. It did. NO_REPLY is filtered everywhere, so nobody was woken to read that nothing happened. A job can also carry a written procedure, bothy schedule add --sop NAME, kept with bothy sop list|show|set, and check its own work: an argv command or a regex on the final output, with up to five attempts. It ends verified or unverified, and unverified raises an alert.
GitHub sends a signed delivery for PR 42. It is on disk before GitHub hears back, and it takes the pr:42 lane, so a second delivery about the same pull request waits its turn instead of racing. This is the wake in Fig. 1.
Halfway through, Codex stops to ask. Bothy does not wait by default, since a turn waiting on a person would hold a slot and a reservation, so the question goes to Discord while the run carries on, and the turn is told plainly that no answer is coming. A run that came from chat, or a foreground bothy run, can be told to wait with question_hold_seconds, within a total cap below the reservation’s expiry: an allowlisted reply or bothy answer answers it, and at the deadline, or on shutdown, it is declined. Discord still hears about it, but a webhook run like this one did not start in a chat thread, so there is nothing to wait on: it declines at once.
! **WARNING** `acme-mini` — run:pr:42
! **WARNING** `acme-mini` — run:pr:42
No. 043316:30
Cairn gets the record
A run started with --ref claims the task in Cairn while it works, writes one note stamped with the run id (cairn note ACME-12 - --kind note), and releases it. The same id is in the audit log, so everything about one run is one grep. Cairn is optional: without it the audit log is the whole record, and a run that cannot reach Cairn still does the work, recording claim_failed. bothy trace ID [--json] [--otlp URL] prints the run as a tree of spans; --json gives OTLP/JSON, and --otlp URL exports it once.
$ bothy run "Summarise what changed this week" --subject weekly --ref ACME-12
$ bothy run \ "Summarise what changed this week" \ --subject weekly --ref ACME-12
No. 05capability per job
A worker starts with nothing. A named profile grants the rest.
A worker starts with nothing beyond Codex’s built-ins. A named profile grants MCP servers, skill roots, a sandbox, and individual tools, by name, because mcp: gmail reads as harmless while possibly including send. Each tool can also carry a verdict in the profile’s approvals list: proceed, deny, guide or confirm. A deny or guide declines, and a guide carries a reason. A confirm waits for an answer when a hold is set, or proceeds on a person’s approval within unless_approved_within seconds, and is otherwise declined. A tool on the ask list with no rule is declined and raises an alert. Every decision is audited. Bothy also hosts tools of its own over the same connection, with no subprocess, port or credential: that is how the agent keeps its standing checklist.
Fig. 6Only named tools are granted, and send_message still has to ask: with no rule to approve it, it is declined
$ bothy run "read the checklist, add an item about the audit chain" --profile selfcarecompleted run_20260918T064159Z_0de1a197 0.0616 usd$ bothy checklist- Disk on / should stay under 85%.- Verify the audit chain is intact, with no missing or invalid links.
$ bothy run \ "read the checklist, add an item \about the audit chain" \ --profile selfcarecompleted run_20260918T064159Z_0de1a197 0.0616 usd$ bothy checklist- Disk on / should stay under 85%.- Verify the audit chain is intact, with no missing or invalid links.
No. 06memory, if you ask
Memory is off until a profile asks for it. Then it is plain files on the machine.
Bothy’s own memory is plain files and a local index under $BOTHY_HOME, outside the workspace. Cairn is a separate, optional ledger; this does not need it.
Persona and notes
Per profile, in $BOTHY_HOME/memory: an operator-written SOUL.md (the persona), and MEMORY.md and USER.md, short curated notes. Writes go through the bothy_memory tool under caps and a scan for secret-looking text, and are audited. Refused text is never written.
Daily notes, searched locally
bothy_memory_note adds to the day’s notes, which are kept out of prompts. bothy_memory_search and bothy_memory_get find them again with SQLite FTS5, or a plain scan where FTS5 is missing. Old days expire after memory_retention_days.
A scheduled consolidation
bothy schedule add --dream, nightly for instance, folds new daily notes into MEMORY.md and USER.md. It refuses to shrink a file by more than 25%, and saves a preimage first. bothy memory preimages lists them, and bothy memory restore puts one back.
Stored on the machine
Plain files and a local SQLite index: no memory service, no cloud store. Like any other context, the persona and notes reach the model in its prompt.
A Cairn brief, when there is one
A run started with --ref reads the task’s Cairn digest first, as fenced, untrusted data: capped at 6,000 characters, with a 10 second deadline, and it never stops the run. cairn_brief: false turns it off. Without Cairn there is simply no brief.
Untrusted runs get no write tools
Webhook and poll runs do not get the memory write tools unless the profile sets allow_from_untrusted.
No. 07six rules
Six rules, each bought with somebody’s outage.
01Budget is reserved, never measured.
With several runs in flight, “are we under the cap” cannot be answered by looking: five runs each see $40 of $50 and all five start. So a run leases its worst case before it begins and settles at the end. Money is an exclusive slot like any other, and a crashed run’s lease expires.
02Workers are killed as process groups.
A Codex app-server is a tree. Signalling the parent leaves the rest alive: thirteen orphans on one host, and 196 children plus $1,193 in one documented runaway.
03fsync, then acknowledge.
A mature broker shipped the opposite as a headline feature and later deleted it, status code and all, because a buffer that had not reached disk could not deliver the durability it promised.
04Refusals are events.
“It did not start, and which gate said no” is the question an operator actually asks. A refusal is retried, counted, and eventually abandoned out loud, never retried forever.
05Liveness is proven, not assumed.
A zombie process satisfies killpg(pgid, 0). Bothy enumerates the group and ignores the dead. That one was found here, not inherited.
06A signature authenticates the sender, not the content.
A verified GitHub webhook proves GitHub sent it. The pull request title inside was written by a stranger, and the prompt says so. Webhook and poll payloads sit inside fences with a fresh nonce, and those runs lose shared memory writes unless their profile sets allow_from_untrusted.
The full list, with the incident behind each rule, is in docs/DESIGN.md.
No. 08try it
Clone it, write six lines of config, and ask it what is wrong.
The systems Bothy learns from are excellent and very large: one is 182 MB of bundled JavaScript across 109 SQLite tables. Bothy is the part you can read in an afternoon and defend to a client. It is what you deploy when the thing has to run somewhere you cannot reach, on hardware you do not own, for someone who will phone you when it breaks.
~/.bothy
export BOTHY_HOME=~/.bothy && mkdir -p $BOTHY_HOMEcat > $BOTHY_HOME/config.json <<'JSON'{ "site": "acme-mini", "workspace": "/srv/work", "pool": { "max_workers": 2 }, "budget": { "mode": "api", "per_run_usd": 0.50, "per_day_usd": 25.0 }}JSONbin/bothy doctorbin/bothy run "Summarise what changed this week" --subject weekly --ref ACME-12bin/bothy status
$ bin/bothy doctor[ok ] state directory writable: /home/you/.bothy/state[ok ] audit chain intact: 14 records verified[ok ] codex present: codex-cli 0.162.1[ok ] cairn reachable: memory and task ledger[ok ] memory FTS5: FTS5 search; missing FTS5 uses a case-insensitive scan[ok ] no orphaned workers: 0 process group(s) left by a previous instance[warn] sandbox can start: bwrap: loopback: Operation not permitted — runs needing a shell will fail[warn] alert route configured: a failure nobody hears about is not handled6 passed, 2 warned, 0 failed$ bin/bothy run "Reply with exactly: BOTHY" --subject democompleted run_20260918T061227Z_a5b90382 0.0199 usd BOTHY
$ bin/bothy doctor[ok ] state directory writable: /home/you/.bothy/state[ok ] audit chain intact: 14 records verified[ok ] codex present: codex-cli 0.162.1[ok ] cairn reachable: memory and task ledger[ok ] memory FTS5: FTS5 search; missing FTS5 uses a case-insensitive scan[ok ] no orphaned workers: 0 process group(s) left by a previous instance[warn] sandbox can start: bwrap: loopback: Operation not permitted — runs needing a shell will fail[warn] alert route configured: a failure nobody hears about is not handled6 passed, 2 warned, 0 failed$ bin/bothy run \ "Reply with exactly: BOTHY" \ --subject democompleted run_20260918T061227Z_a5b90382 0.0199 usd BOTHY
Then install it
One command, no prompts, no TTY, idempotent, non-zero on failure.
sudo bin/bothy install --dry-run
exactly what it will touch
sudo bin/bothy install --serve
launchd or systemd, and tailscale serve
bin/bothy funnel
the tailscale commands your config implies
The exit code is a contract: 75 restart me, 78 stop. The two service managers need opposite handling of it, which is in the runbook.
No. 09status
What has been proven, and what has not.
Every test was demonstrated by hand against the real thing before it was written down. The tests are recorded evidence, not a guess at what the code does. Which makes the list of things never tried against the real thing worth printing just as large.
Proven end to endreal Codex · real GitHub · real systemd
Supervised turns against a real Codex app-server
Parallel runs, and budget refusal on live quota
Crash recovery under SIGKILL
A heartbeat that woke itself and reported a genuine sandbox failure
A hash-chained audit trail joining all of it by run id
A real systemd install
The memory layer, live against real Codex: persona, writes and recall, search, a scheduled consolidation with a restore, and the untrusted gate
Runs as a per-user LaunchAgent on a Mac
Not proven yetsaid out loud
No live Slack workspace has ever been connected, or answered a held question
No live Discord application has ever been connected, or answered a held question, and its replies to Drove attention items have only run against a stand-in
No chat answer or approval has completed a live Codex round trip
No test pins Bothy to Codex’s generated schema: the shapes were checked once, against Codex 0.162.0
bothy trace and the OTLP export do not yet show interrupt/* records (questions, answers, late replies); those are in the audit log only
The consolidation’s 25% drop guard has not been triggered live, only in tests
An untrusted run with allow_from_untrusted was shown to receive the memory write tools, but not to write
The reply-capture fix is proven against a fake app-server, not a recorded live event sequence
No Slack or Discord run has used memory
The protocols, ack discipline and allowlists are proven against fakes and the RFC. The credential paths are not
No. 10the family
Bothy is the piece left on site. The others are what it leans on.