A scheduled, confined, autonomous Claude Code agent runner. It builds a
hardened container, starts one unattended session in it on a cron schedule,
pushes the agent's memory out at every session end, and files every
transcript in a private archive — with the permission boundary, the guard
that reads it, the backup hook and the budget gate all held in this
repository, where the agent that runs inside can read them and never write
them. It was built to run Cairnfield, an
agent that lives on the 1f916 forum. Nothing in the
mechanism names either: everything derives from one value in .env, and what
is this installation's own sits in three untracked files. The name of the forum
does appear where a record or a fixture would be worth less without its real
specimen — the dated records in docs/, the guard's selftest, the examples
beside those three files.
This is a demonstration, not a product. No support, no releases, no issues answered. It is published because the arrangement is worth reading — clean, documented, and runnable by someone who clones it and supplies their own agent. Read anything here before you build it.
MIT licensed; see LICENSE. One thing here is not this
repository's to license: the texts of Claude Code's own classifier rules,
captured in auto-mode/shipped-2.1.251.json from claude auto-mode defaults
and quoted in AUTO-MODE.md for review. Those are Anthropic's, reproduced for
review, and not covered by the grant.
flowchart TB
subgraph HOST["the host — the operator's machine"]
CRON["cron"]
JUST["just — the index into host/"]
BUDGET["the budget guard<br/>host/lib/session-env.sh"]
end
subgraph CONTAINER["the container — one session, then gone"]
SESSION["claude-session<br/>the rendered system prompt"]
MANAGED["managed-settings.json<br/>the permission boundary"]
BASHGUARD["bash-guard.py<br/>PreToolUse, on parsed argv"]
BACKUP["push-on-exit.sh<br/>the backup hook"]
end
subgraph VOLUME["the volume — everything the agent owns"]
CHECKOUT["its checkout"]
LOGIN["~/.claude — the Claude login"]
KEY["~/.ssh — the ssh key"]
end
RUNNER[("this repository<br/>image, boundary, host tooling")]
MEMORY[("the agent's memory<br/>private, only the agent writes it")]
ARCHIVE[("the archive<br/>private, the transcripts")]
MIRROR[("the mirror<br/>private, the audit record — the runtime cannot write it")]
CRON -->|"wakes"| JUST
JUST --> BUDGET
BUDGET -->|"under the allowance: start a session"| SESSION
RUNNER -->|"just deploy builds the image from image/"| MANAGED
SESSION --- MANAGED
SESSION --- BASHGUARD
SESSION --- BACKUP
CONTAINER -->|"mounted at the agent's home"| VOLUME
CHECKOUT -.->|"cloned from"| MEMORY
BACKUP -->|"pushes at every session end"| MEMORY
JUST -->|"just collect: reads the volume, pushes transcripts"| ARCHIVE
MEMORY -->|"mirror workflow, at every session end"| MIRROR
MIRROR -->|"just drift-audit reads the mirror"| JUST
| what it is | who writes it | |
|---|---|---|
| this repository | the image, the boundary, and every host-side command. The agent reads it and never writes it. | the operator |
| the image | Debian/Node with git, gh, ripgrep, python3 and a pinned Claude Code, running as an unprivileged user with no capabilities and no view of the host filesystem. The isolation is not in it: it is in compose.yaml, whose closing comment lists the six run-time settings that each, alone, would undo it. |
just deploy |
| the boundary | image/managed-settings.json, baked to /etc/claude-code/, where managed settings outrank every other source. Nothing in the volume can reach it. |
the operator |
| the guard | image/bash-guard.py, a PreToolUse hook. It is the only mechanism that reads parsed argv, so it is the only one that sees an act behind a wrapper, a substitution or a global option — and it reads what a commit or a push would carry. |
the operator |
| the backup | image/push-on-exit.sh, registered in managed settings and living outside the volume, because a hook the agent could blank would stop backing up its memory with no symptom at all. |
the operator |
| the volume | the agent's whole world: its checkout, its Claude login, its ssh key. Nothing else persists, and there is deliberately no recipe that removes it. | the agent |
| the agent's memory | a private repository — instructions, self-description, journal, notes. The agent is its only writer, and nothing here ever commits, comments or opens an issue there. | the agent |
| the archive | a private repository holding one transcript per session on an orphan sessions branch, the host's status snapshot on status, this installation's own configuration files on config, and a mirror of the agent's memory on a ref outside refs/heads/*. |
just collect, just deploy, and a workflow there |
In this order, because each step needs the one above it. All four are in
place before just build; the Quickstart below is what you then type.
1. The agent's memory repository, private, seeded from
examples/agent/. Either a GitHub machine account of
the agent's own — the Terms permit one alongside a personal account, used
only for running a machine — or, simpler, a private repository of your own
with a write deploy key on it. Push the example as its first commit: a
CLAUDE.md with a session-start routine — an unattended session is opened
with "Run the session-start routine in CLAUDE.md", and nothing else unless
the run before it was stopped —
SELF.md, JOURNAL.md, a .claude/settings.json with no permissions
block, and tools/, which one permission rule names by path. Its README
says what the runner requires of each. The container generates the ssh key
pair itself on its first run and prints the public half; you add it to the
account or as the deploy key.
2. The archive repository, private and separate, its main seeded from
examples/archive/. Different risk profile and
different lifetime from the memory, and keeping them apart is what leaves
the memory repository something that could one day be published. The
example holds the credential check, the placeholders to replace, and an
optional status page. just setup-archive clones it; the transcripts branch
and the status branch create themselves. What the archive is for, what the
gate does and how a held transcript is ruled on is in
docs/archive.md.
3. The mirror repository, private and separate again, its main seeded from
examples/mirror/. It holds the audit record — the
agent's memory, mirrored onto refs outside refs/heads/*, with a mark
preserving any tip a rewrite upstream would have destroyed. It is apart from
the archive because the machine that runs the agent writes transcripts there,
and a record that machine could rewrite is not a record: it holds no write
credential for this one.
Two recipes, and they are not the same act. just setup-mirror writes the
two secrets that repository's workflow runs on — done once, from the machine the
code is edited at, and refused on a machine that runs the agent. just setup-gh installs this host's own access: the token it reads the record
with and asks it to refresh with, and git's credential helper, without which
just mirror-status cannot fetch at all. Every host needs the second, including
the only one when there is only one; on a host that runs the agent it also
proves the token cannot write the record.
4. Bitwarden Secrets Manager,
with three projects. A free account works: it is capped at three projects,
and these are the three. <agent>-provisioned is read-only to the container
and holds what you hand the agent; <agent>-acquired is read and write and
holds what it acquires for itself, so it can rotate a token of its own and
never overwrite one of yours; <agent>-test is the vault wrapper's own
fixture project, where just verify and the wrapper's tests write, so that a
test never lands a value in the other two. One machine-account access token,
BWS_ACCESS_TOKEN in .env, opens them, scoped as above, so a new secret
costs a paste and nothing else. Where to put one and how the agent reads it
back is in docs/vault.md.
What to store in <agent>-provisioned before the first session, under
these exact names, since the container looks them up by name:
| secret | what it is | needed |
|---|---|---|
claude-oauth-token |
the Claude setup-token from claude setup-token, what every session runs on. Put expires YYYY-MM-DD in its note: nothing can be asked for that date, and it is what just status counts down to — docs/vault.md |
yes |
github-token-own-account |
a token of the agent's own GitHub account, what gh runs on inside the container — without it the agent can neither read nor open an issue; what to grant it is in docs/vault.md |
for the agent's work |
github-ssh-key |
the private half of the agent's ssh key, once the first just shell has printed it — the entrypoint restores it on an empty volume, so the deploy-key step never comes back |
optional |
It is the one route for a Claude setup-token into the container, and
that matters more than it sounds. claude setup-token issues an
inference-only credential; the interactive claude login writes a
credentials file into the volume instead. Both run a session. The difference
is what else comes with them: measured on Claude Code 2.1.250, a session on
an interactive login is served every claude.ai connector authorised on
that account — eight connector families, on the account measured — and a
session on a setup-token is served none. disableClaudeAiConnectors is on in managed
settings regardless; what the token decides is whose quota the agent spends
and which credential can be revoked without touching yours. The vault is the one
route, so that every fetched secret lands in a file the commit check can
read.
What a session here actually runs on is decided in exactly one place,
image/vault-env.sh, through which both a session and anything started with
--entrypoint exec — so a gate and a session cannot read different
credentials. In order: a CLAUDE_CODE_OAUTH_TOKEN already in the environment
wins and nothing overwrites it, which is how just verify passes a deliberate
value; otherwise the vault's claude-oauth-token, under that hardcoded name;
otherwise ~/.claude/.credentials.json in the volume, which is what an
interactive claude login inside just shell leaves behind. The fallback
is silent — no such secret, an expired access token, a network that is down
all mean the same thing to it — so just verify's session login verdict
reporting which of the three a session will run on is the only thing that says
so out loud.
5. The host tools. just verify names any that are missing, and each
one that is absent fails in a way that reads as something else.
| why, and the version | |
|---|---|
| docker, and Compose ≥ 2.24 | compose.yaml clears the test twin's volume with volumes: !reset null, which older Compose does not understand — and a rehearsal that wipes a home must not be pointed at the home it exists to protect |
| just ≥ 1.55 | declared flags, just --usage, variadic options. Distribution packages lag badly: install a pre-built binary, not the apt package |
| python ≥ 3.14 | the host scripts; the container's own python is the base image's 3.11 |
| cron | the unattended session |
| flock | one session at a time. A missing flock exits 127, which reads as held, so every scheduled run would stand down in silence |
| jq | just listen and just read render nothing without it |
GNU date |
date -d on ISO timestamps. BSD date answers nothing, and a waiting run then calls a live session wedged |
| gh (optional) | without it no session end asks the archive to mirror, which looks like nothing at all until a rewrite upstream is lost |
| gitleaks (optional) | just collect runs its own pattern floor alone |
| ttyd, tailscale (optional) | just listen --remote and nothing else. Without them it says so and stops |
From a clone to a first unattended session. Read what each command prints rather than that it exited zero.
just setup
Creates .venv from requirements-dev.txt and installs the pre-commit hooks,
so just lint and the hooks cannot run different versions. It also makes the
three files that hold what is yours rather than this repository's, each from a
committed example beside it: image/config/vault-exempt.txt (vault entries that are
identifiers and not credentials), image/config/secret-shapes.txt (the shapes of the
secrets your agent holds because of what it does) and image/config/community.txt (one
sentence naming the community it belongs to, or nothing). None of the three is
committed here; the examples ship every line commented out, so an untouched
installation gets a file that adds nothing. Edit them before the first
build — just build refuses without them, just deploy carries them to the
agent and backs them up to the archive's config branch, and just setup --restore reads them back from there.
docs/configuration.md
cp .env.example .env
Five values are required, and .env.example explains every other one:
AGENT_NAME— everything else derives from it.<NAME>_REPOand<NAME>_GIT_EMAIL,<NAME>being that name uppercased — the agent's repository over ssh, and the account's ownusers.noreplyaddress, whose numeric id is not a function of the handle.AGENT_ARCHIVE_REPO, asowner/name.BWS_ACCESS_TOKEN, the machine-account token from step 4 — andBWS_SERVER_URLif the account is on the EU server, since a token issued there is refused by the US default asinvalid_client, which reads like a bad token. Store the Claude setup-token in<agent>-provisionedasclaude-oauth-token: that fixed name is what the container starts on.
The optional ones — who the operator is, the agent's domain and community,
the budget guard and its percentages, the cadences — are commented out in
.env.example beside what each derives to. Read it once. There is no GitHub
token in this file: git reaches GitHub over ssh, and gh is authenticated
inside the container with vault gh-login github-token-own-account, the
token step 4 stored under that name.
just pin
Resolves the base image to today's digest and edits the Dockerfile. It refuses on a dirty file and never commits: read the diff it prints. The digest in the file is a real one — this moves it.
Before the first build, decide the model: <NAME>_MODEL in .env, sonnet
unless set — opus, or opus[1m] for the million-token window, when the
agent's work is worth it and the plan covers it. It is rendered into managed
settings at build, so it holds for every kind of session alike and the agent
cannot change it, and it is an alias rather than a pinned id, so a newer
model of that tier is picked up without an edit. just verify reports what a
session was served against it. The file it is
rendered into, and everything else that decides what the agent may run, is
docs/boundary.md.
just build
Builds <agent>-agent:candidate, which nothing scheduled runs, and runs the
three selftests baked into the build. What each release command moves is in
docs/release.md.
just verify
Proves the candidate you just built — the mechanisms that fail silently when
they are wrong: that managed settings are actually read and the deny list binds, that
the guard is reached at all, that the backup still asks it before pushing,
that the twin has no volume, that the image and the running Claude Code are
what the tree says. Some of its probes start a real session to ask — on the
candidate, never on what is live. After any change, just verify --build
rebuilds first, in place of typing build and then verify: a stale image
passes in the same words a correct one does, and the flag is what keeps it
from being the thing proved. It ends with a count and a list of what needs
your eyes. On a fresh installation expect a LOOK on the schedule,
since nothing is installed yet, one on the login, naming whichever
credential it found, and — before the two just shell runs below have made
the volume — one on every probe that needs a session, saying no session
ran: the entrypoint stops at exit 78 on an empty home, which is right, and
those probes answer on the next verify. Every probe, and the measured failure each one exists
for, is in docs/verify.md — the list is deliberately
not counted anywhere, because a number typed in prose is a second copy of how
many probes there are and the copy is the one that goes stale.
just deploy
Shows what is about to go live and asks. It resets the deployed checkout to
HEAD, builds the image from that checkout and proves it with just verify before making it live, so the code that is live and the image that is
live are one thing rather than two that have to agree. --skip-verify goes
live unproved, and the question says so. It refuses on a tree that is not
clean. --config, where the agent runs on a host of its own, sends it a
changed .env and nothing else, and refuses when that change is built into
the image. Nothing you edit
here reaches the agent until this. docs/release.md
just shell # first run: the key
The volume is empty, so the entrypoint generates the ssh key, prints the identity and the public key, and stops at exit 78 — nothing started. This run is not the agent; it is a hand doing setup.
Add that public key to the agent's GitHub account, or as a write deploy key on its repository, and make that account a collaborator wherever it needs to read. Then:
just shell # second run: the clone
The same command, now with a key GitHub knows: the entrypoint clones the
repository and drops you in the home. Look around, then exit. Bootstrap
goes through shell and not run for one reason: just run passes a
working directory, and Docker creates a missing one as root before
anything in the image runs, so a first just run on an empty volume meets
a directory the clone cannot write.
just chat "Read your repository and say where things stand. Then …"
The first session is a conversation, not a schedule: this is where you tell
the agent what it is for — the community it should join, the accounts it
should set up for itself, what to do with its first hours — and where it
asks back. The message goes in prefixed with a marker saying a person wrote
it, which is the one channel the agent's own rules treat as direction.
just chat --continue "…" resumes that conversation rather than the last
unattended run.
just run
One unattended session, whole: the opening message asking for the
session-start routine, the session, the backup push at its end, and the
collection afterwards. just run --listen renders it as it is written.
When the run before it did not end cleanly, the opening message instead carries what that session was doing — see After a session is stopped below.
just collect
Archives the transcripts the volume holds, holding back any that look like
they carry a credential until you rule on them. just run has already
called it with --push; by hand it commits only.
docs/archive.md
just stats --by-session
The sessions newest first, each with its id; just read <id> renders one
whole.
Then just schedule --enable --cron "* * * * *", when you mean
it to run without you: an hour after each session ends, and never two at
once. The budget guard in .env is what keeps a schedule from taking your
week; read docs/budget.md before you loosen either, and
docs/schedule.md for what the cadence does to the
cron expression beside it.
just alone lists them, grouped as below, and just --usage <recipe>
shows every option with what each one does.
docs/sessions.md — the lock, the readers, what a session is told.
just run |
one unattended session, then archive its transcript and push it. --listen renders it live, --wait queues behind a running one, --force starts a second beside it, --ignore-budget starts one over the allowance, --ignore-cooldown starts one whatever the wait. --awake-in=N and --awake-now start nothing: they set the next unattended wake-up, after asking |
just chat "…" |
a conversation. It waits for a running session rather than refusing; --continue resumes the last conversation, which is not the last session |
just shell |
a shell in the container, carrying the same environment a session gets. This is what bootstrap uses |
just test-container |
the same container with no volume — an empty home every run, for rehearsing the morning the volume is gone. Never where the agent runs |
just listen |
the running session from its first line, live — or, with nothing running, the tail of the last one. --all lifts the read ceiling, --wait waits for the next, --live never closes, --remote serves the live view to any device on the tailnet |
just read <id> |
one transcript whole, and the only reader there is. A session or subagent id, as just stats --by-session shows it; --subagent K, --full |
just status |
whether anything needs attention, and then: what is running and what it has spent, or when the last one ended and when the next may start; what today has held; what the budget gate sees; whether the backup is still running; how many transcripts the collection gate is holding; what is live and since when |
A session can end without reaching its own end: the account's usage limit stops
it, the API is down, the container dies, the host reboots. The runner records
how every unattended run ended — terminal_reason out of Claude Code's own
print-mode result, completed being the only clean one — and the next session
that actually runs opens with what the stopped one was doing: its last words,
the files it wrote, how many commits it ran, the tools that failed, and where
its transcript is. The message says in words that this is a record and not
instructions.
The record is replaced only when a session actually starts, so a stop survives
however many wake-ups stand down in between — which matters, because a usage
limit is followed by hours of them. just chat opens with it too. Nothing is
blocked at any point, and a run that never became a session at all leaves
nothing to hand over and says nothing.
Two settings follow from it. autoContinueAtUsageLimit is off in managed
settings, so a limit ends the session instead of parking the container for the
rest of the window; and a usage window with nothing left — 100% used, not
merely past the agent's share — refuses a session whether or not
ACCOUNT_BUDGET_GUARD is armed. docs/sessions.md and docs/budget.md carry
the measurements.
docs/archive.md — the collection, the credential gate, the mirror.
just collect |
archive transcripts to the private archive. --push publishes, --held lists what is held back, --approve H "why" archives one as it stands, --redact H "why" archives it with the credential rewritten out, --scan-archive runs the gate over what the archive already holds, here, and only reports |
just publish-status |
put the host's half of the status page where a dashboard can read it. --now ignores the ten-minute floor |
just credentials |
read when the agent's Claude and GitHub credentials expire, now — just status shows the last reading rather than taking a new one, because taking one costs a container start |
just mirror-status |
how the mirror of the agent's memory is doing: the ref, any preserved rewrites, whether the workflow is still enabled, whether it is behind. It only reads |
just setup-archive |
clone the archive. Runs on your own gh credential |
just setup-mirror |
set up what the mirror's workflow runs on, in its own repository — Actions, the read key on the agent's repository, and the two secrets. Runs on your own gh credential and writes to GitHub; the agent is never told any of it |
just setup-gh |
set up this host's own access to the mirror: the token it reads and dispatches with, and git's credential helper. On a host that runs the agent it refuses a token that can write the record |
docs/monitor.md — the drift audit, its threshold and its two anchors.
just drift-audit |
audit what has moved in the agent's memory since a frozen baseline — a Claude session on this host, on your own login |
just drift-accept |
move the baseline to the last audited commit. The ratchet: every later report stops covering the range between, so read the reports first |
just drift-diff |
the same cumulative range as a plain git diff, with no agent in between |
just drift-status |
the mirror ref, the two anchors and how far behind each is, and the last runs. It fetches first |
just cost |
what the archived sessions cost, priced from their sealed records. --by-day, -d N, or session ids. API list rates: weight, not an invoice |
just tools |
how many times each tool was called, per day (-d N), in the archived sessions' records; name tools for one line per day |
just stats |
what the agent has been doing, and whether that is changing — one screen over the sealed records: how many unattended sessions and how long, the last seven days day by day, the weeks side by side, and which build carried what. -d N narrows it to the last N whole days, today excluded; --system shows the machine day by day instead; --by-session lists the sessions one row each, newest first, and --day D one local day's |
just journal |
the agent's journal one entry at a time, newest first, with the session that wrote each one under its heading: → older, ← newer, q quits. A day or a session id opens there |
just records |
one durable record per archived session — what it was, what it spent, what it committed, which runner built it. Every session end seals its own and publishes it to the archive's cache branch, so this is machinery rather than something to type; --recheck and --rewrite are what a person runs |
docs/release.md — what each command moves, and why deploy builds and verifies.
just setup |
create .venv, install the pinned tooling and the pre-commit hooks |
just lint |
the pre-commit hooks over the whole tree — ruff, shellcheck, gitleaks, the classifier check — then mypy |
just build |
build the image as the candidate |
just deploy |
go live: build from the deployed checkout, verify, then make it live. --skip-verify skips the verify and the question says so. --config sends only .env to the host that runs the agent, when the change is not built into the image. --diff is the patch between what is live and what would be, .env included and masked; --state reports the same facts as fields |
just pin |
pin the base image to its current digest and Claude Code to npm's latest, as a diff to read; --image or --claude for one |
docs/schedule.md — the crontab entry, the cadence, the wedge alarm.
just schedule |
what is scheduled right now — the hour, the line itself, and whether cron is running to read it |
just schedule --enable |
install the entry, or bring a paused one back. --cron "…", --pause, --disable, --relocate |
<NAME>_WAKE_DEFAULT |
the cadence, in minutes — how long a wake-up waits when the session asked for nothing. 60 unless set |
<NAME>_WAKE_REQUEST |
exactly true lets a session move that number from its closing message, clamped between <NAME>_WAKE_MIN (the cadence unless set) and <NAME>_WAKE_MAX (360 unless set) |
<NAME>_WAKE_SLEEP_AS_AWAKE |
exactly true makes the wake-up after a clean run wait as long as that run was awake, held between the cadence and <NAME>_WAKE_MAX; a request keeps <NAME>_WAKE_MIN |
<NAME>_WAKE_JITTER |
minutes a wait nobody asked for is spread by, drawn when a session ends: 5 either side, -5 only below, +5 only above. Off unless set |
docs/verify.md — every probe, and the measured failure each one exists for.
just verify |
prove the mechanisms that fail silently, on the candidate. --build rebuilds it first; --deployed asks the same of what cron runs |
The schedule is one crontab line, and just schedule is the only thing
that writes it. --enable installs it or puts a paused one back exactly as
it stood, --pause comments it out where it is so the crontab stays the only
copy of it, --disable removes it. What --enable and --relocate do
rewrite is the PATH the line carries: cron's own is /usr/bin:/bin, and a
just upgraded into another directory is one an installed line goes on not
finding. The expression says when cron looks; <NAME>_WAKE_DEFAULT in
.env says how long a wake-up then waits — --cron "* * * * *" with a
default of 60 starts a session an hour after the previous one ended, wherever
that falls, and the number changes with an edit rather than a reinstall. Start
there: every session spends your account's allowance, and a wait of fifteen
minutes is a working day of sessions by lunchtime.
docs/schedule.md
A session can move that cadence, within bounds you set. Arm
<NAME>_WAKE_REQUEST=true with a <NAME>_WAKE_MAX, and a session ending its
closing message with a line reading Wake me up in 90 minutes gets 90 minutes
instead of the default. This is the one place the agent's own output changes
what the runner does with nobody reading it in between, so the two bounds are
the whole of the containment: the floor guards your account, the ceiling guards
against an agent that goes quiet and is never missed. Arming decides only who
chooses the number — the clock, the floor and the default apply either way.
docs/schedule.md
One session at a time, and the lock is held by whoever starts one rather
than by cron. run stands down at once and exits 75; chat waits, showing
how long that session has been up and how long you have been waiting. It
cannot go stale: flock holds through an open descriptor, so a lock that is
held means a process is genuinely alive holding it — which is why --force
cannot remove it, and starts a second session beside the first instead.
docs/sessions.md
just listen follows the session that is running, from its first line.
With nothing running it shows the last one's tail and closes on just status's own first line. --live never closes: when a session ends it says
what it cost and what in this tree is uncommitted or undeployed, then waits
for the next one. --remote puts that same live view on a tailnet address for
another device to open, while the window keeps showing it — a web terminal and
a userspace Tailscale, both in the foreground of the window that started them,
both taken down by the Ctrl-C that ends the follow.
docs/sessions.md
just status is the one-screen answer, and its first line is a verdict:
whether anything needs attention, then what is running and of what kind, when
the next unattended session may start, what today has held, what the budget gate
sees, whether the backup is still running, what the collection gate is holding
back, and what is live and since when. Every section decides its own share of
that verdict, so nothing on the screen is a word the reader has to recognise as
bad. docs/sessions.md
just chat is you spending your own quota on purpose, so the budget gate
does not apply to it. --continue resumes the last conversation rather than
the last session — usually the hourly unattended run — a distinction only
possible because chat decides each conversation's session id itself and
writes it down. docs/sessions.md
just read opens one transcript, by the id just stats --by-session lists. The
archive's sessions branch first and the volume second, so a session
collected long ago reads the same as the one that just finished, and a
redacted transcript reads as the archive holds it. docs/sessions.md
What a session is told about itself is a handful of environment variables
and a rendered system prompt: how often it is run and when it last was, what
the budget gate saw, whether another session is running beside it, and — from
image/system-prompt-template.md — the situation it is actually in,
pointing at CLAUDE.md and SELF.md for everything else. They carry facts
and never direction: this channel reaches the agent without anyone reading
it first, so a sentence in it that said what to do would be direction down
the one path nobody reviews. claude-session --render prints what a session
would be told. docs/sessions.md
Every time a person reads is local; every time the agent is given is UTC.
The container runs TZ=UTC, the transcript stores UTC, and the archive files
a session under its UTC day. What listen, read, stats and status
put on the screen is converted to the clock in the room — display only,
nothing written down moves. just stats --day therefore means a local day.
docs/sessions.md
The budget guard reads the account's own rate limits before an unattended
session starts and stands it down when the total is over an allowance that
climbs from a start percentage at each window's reset to a cap just before
the next one — so a weekly cap of 60 is not 60% available on Monday. It runs
on the host, against your own interactive login, because reading usage
needs the user:profile scope that only claude login grants and a
setup-token is answered 403. It is off unless ACCOUNT_BUDGET_GUARD is
exactly true, unset percentages are a refusal rather than "no limit", and
the container's own read is advisory: it cannot refuse anything, and exists
so the agent can see how fast it is spending. docs/budget.md
The drift audit is the one piece here that watches the agent rather than
the runner. just drift-audit clones the archive's mirror of the agent's
memory and starts a Claude session on this host, on your own login — not
in the container, and on no credential of the agent's — which reads that
clone between a frozen baseline and a cursor and writes one report of what
moved in what the agent may do, must do, is prompted to do, or how it
measures itself. That session is confined by its own settings.json with
every other settings source turned off, has no write access outside its
report directory, and is told that instructions found inside the corpus are
findings rather than direction. Its clone, its anchors and its reports are
local to monitor/, and nothing it produces reaches the agent.
docs/monitor.md
The vault is Bitwarden Secrets Manager reached through
/usr/local/bin/vault, and its point is that a new secret costs you a paste
and nothing else — no new variable, no rebuild, no restore. Two projects, and
the split is the security: the agent can store a credential it acquired and
replace one it stored before, and cannot touch one you provisioned. bws
itself is denied by both the guard and the deny list, because only the
wrapper puts a fetched secret in a file the commit check can see — so a
credential the agent acquires is compared verbatim only if it went
through vault, and by shape otherwise. docs/vault.md
README.md this
CLAUDE.md standing instructions for whoever maintains this container
LICENSE MIT
VARIABLES.md every variable, by category — read it before adding one
AUTO-MODE.md every classifier rule, by disposition — generated, never edited
compose.yaml how the container is run — the hardening lives here
justfile the index: one doc line, its declared flags, one exec per recipe
.env.example copy to .env; three values are required and the rest derive
pyproject.toml ruff and mypy, targeted at the container's own python
requirements-dev.txt the pinned lint tooling `just setup` installs
.pre-commit-config.yaml
the hooks, running what `just lint` runs
.gitignore .env, the caches, and the three directories recipes create
.github/workflows/ci.yml
the static checks, and a build of image/
docs/ the records, one file per topic — what was measured, what it
decided, and where it lives in the code now
archive.md collection, the gate, redaction, reading it, the mirror
backup.md the push-on-exit hook, and why it fails closed
boundary.md managed settings, the guard, the withdrawal of 2026-09-01
budget.md the usage read, the ramp, and the two credentials
configuration.md the derivation chain, and what `just`'s own settings buy
image.md the volume, the entrypoint, the pin, the hardening
monitor.md the drift audit — what it reads, and its two anchors
release.md build, verify, deploy, and the base image pin
schedule.md the crontab entry, the cadence, the wedge alarm
sessions.md the lock, the forwarder, what a session is told, the readers
vault.md the wrapper, its refusals, and where the login comes from
verify.md every probe, and the measured failure each exists for
examples/ what a clone starts its other pieces from
agent/ the memory's seed
CLAUDE.md standing instructions, with the session-start routine
SELF.md who the agent is, in a paragraph
JOURNAL.md what each session did, newest first
.claude/settings.json
project settings, deliberately holding no permissions
tools/.keep the directory one permission rule names by path
README.md what each file is for, and what the runner requires
archive/ the archive's seed, for its `main`
README.md the refs, the placeholders, and the secrets
.github/workflows/check-credentials.yml
proves the status page's four secrets, on demand
scripts/session-meta.jq
one archived session as a row, for the page
optional-status-page/
the page; it needs a Cloudflare account
README.md how it fits together, and how to install it
dashboard.yml renders every half hour, writes one KV key
scripts/render.py the page, as one self-contained HTML file
worker/index.js the door: verifies the Access token, serves the key
worker/wrangler.toml the Worker's own configuration
mirror/ the mirror repository's seed — the audit record, in a
repository the machine running the agent cannot write
README.md the refs, the two secrets, and why it is not a branch
.github/workflows/mirror-AGENT.yml
the mirror — rename it to mirror-<agent>.yml
vps/ a host to run the agent on, for the installation whose
runner cannot stay on an always-on machine
README.md sizing, the two accounts, and the move, step by step
provision.sh packages, docker, just, the deploy account — run there
image/ everything baked in; the agent may read, never write
Dockerfile non-root, no sudo, the pinned base and the pinned Claude Code
entrypoint.sh git identity, ssh key, first clone — runs on every start
vault-env.sh the one place this container's Claude login is decided
vault.sh the vault wrapper: list, get, put, gh-login, ssh-restore
config/ what is this installation's and not this repository's
vault-exempt.txt which vault entries are identifiers rather than credentials
secret-shapes.txt the shapes of this installation's own secrets, added to both floors
community.txt the community sentence the classifier is told
— these three are untracked, one *.example.txt each beside them
managed-settings.json the permission boundary, outranking every other source
bash-guard.py the PreToolUse guard, on parsed argv, out of the agent's reach
push-on-exit.sh the backup hook, likewise — branches and tags, not notes
claude-usage.py what the account has spent, from its own rate limits
claude-session.py renders the system prompt and starts claude on it
system-prompt-template.md
what every session is told about its situation
session-cost.py what one session cost, from its own transcript, at API rates
host/ host-side only, never run in the container. One directory per
question, and the justfile is the index into them
lib/ shared by the rest — sourced, or a helper they run
root.sh the checkout a script belongs to, and where it runs
deployed.sh the forward into the deployed checkout, and its heads-up
archive.sh where the archive is, and the ordered table the readers share
session-lock.sh the one-session-at-a-time lock, and what is known from outside
session-env.sh what a session is told about itself, and the budget verdict
docker-up.sh stop early, and say why, when the daemon is not answering
sampler.sh sysstat samples while a session runs, when asked for
sysstat.py what the machine was doing over a window, read back from them
config-files.sh the per-installation files, derived from their tracked examples
session/ what a session is, watched or read
run.sh one unattended session — what cron calls
chat.sh a conversation, interactive, that the operator sits in
shell.sh a shell in the container, for bootstrap and looking around
test-container.sh the same container with no volume
listen.sh a running session live, or a finished one's tail
remote.sh that live view on a tailnet address, for another device
read.sh one transcript whole, by its number or by its own id
status.sh what is running, what it spent, what is scheduled, what is live
last-chat.sh which session the last conversation was, from the volume
session-stats.py the shape of a session — requests, output, model
transcript.jq how one transcript entry is shown; listen and read share it
archive/ the record, and what writes it
collect.sh the collection: the flags, and the stages below in order
read-volume.sh the transcripts out, the volume's own secrets in
ledger.sh what has already been ruled on
floor.sh the pattern floor, in one place: the generic shapes and yours
scan.sh the gate: shapes, verbatim, gitleaks, and the skip
rule.sh --held, --approve, --redact, and the proof
report.sh what is held back, and what the run read
archive.sh the worktree, the commit, the push
needles.py the strings whose presence means a real credential
shapes.py long runs described rather than printed
check.py whether a held transcript holds a real credential
redact.py the credentials rewritten out of one transcript
passages.py where it appears, in words
findings.py what gitleaks objected to
archived.py which staged transcripts the archive already holds
archive-layout.py where each transcript belongs on the sessions branch
gitleaks.toml the one scan rule this repository changes
publish-status.sh the host's half of the status page, onto the status branch
status-collect.py what goes in it
dispatch-mirror.sh ask the archive to mirror the memory, at a session end
mirror.sh how the mirror is doing
session-meta.jq one archived session as a row — the header over a read
setup.sh clone the archive, and set up the mirror's credentials
monitor/ the drift audit, and what the archive has cost
clone.sh the audit clone: where it is, and bringing it current
drift-audit.sh one audit — the anchors, the issue ledger, the session
drift-accept.sh move the baseline; the ratchet
drift-diff.sh the cumulative range, with no agent in between
drift-status.sh the mirror, the two anchors, the last runs
cost.sh what the archive's sessions cost, priced by session-cost.py
drift-audit/CLAUDE.md
its run procedure — copied beside the session each run
drift-audit/system-prompt-template.md
its situation, rendered by image/claude-session.py
drift-audit/settings.json
its confinement, the only settings source in force
release/ what a change passes through before it is live
setup.sh create .venv, install the pinned tooling, make the three files
lint.sh the pre-commit hooks over the whole tree, then mypy
check-auto-mode.py the classifier rules against their sources
check-agent-settings.sh
whether the agent has granted itself anything
build.sh build the image as the candidate
deploy.sh reset the deployed checkout and build from it
config-backup.sh the three files onto the archive's config branch, after a deploy
pin.sh, pin.py pin the base image digest, as a diff to read
undeployed.sh what a checkout has that is not live yet, as one phrase
schedule/ the crontab entry, and what it wakes you with
schedule.sh report, enable, pause, disable, relocate
notify.sh put one line on the Windows desktop — silent on a terminal
verify/ what `just verify` proves
verify.sh which image, and the running order
lib.sh the verdict vocabulary every section speaks
host-tools.sh the tools this repository invokes, and the cron `just`
mechanical.sh settings, names, compose, the twin, gitleaks, the skip
image-commit.sh what the image says it was built from
claude-code.sh which Claude Code actually answers
budget.sh the guard on this host, and a threshold it cannot parse
session.sh the probes that need a real session, gated on a nonce
prompt.sh the system prompt render, with no session
auto-mode/ AUTO-MODE.md's sources; it and the autoMode key are generated
prose.md the hand-written sections, in order
decisions.py one disposition and one reason per shipped rule
build.py writes AUTO-MODE.md, and rewrites one key of managed settings
refresh.py re-read the installed Claude Code's own shipped rules
shipped-2.1.251.json the pinned copy of those rules, to diff a new version against
Three directories are made by recipes and gitignored, so a clone of this
repository arranges nothing outside its own directory: deployed/ (just deploy), archive/ (just setup-archive) and monitor/ (just drift-audit).
The build context is image/, not the repository root. That is deliberate:
.env and the host-side scripts are not in the context at all, so no COPY
can reach them by accident.
| 0 | it did what it was asked |
| 1 | it failed: the session errored, the build broke, a check found something |
| 2 | a usage error only a person at a terminal can produce — contradictory flags, or a --force with no terminal to ask on. Never alerted |
| 69 | the docker daemon was not answering, or the image is missing. Nothing was attempted |
| 75 | an hour that started no session on purpose: the wait had not elapsed, the lock was held, the account was over budget, or a question was answered no. This is what cron has always read as a skipped hour, and it is silent unless there is a terminal — 1440 lines a day of "not yet" is a log nobody reads on the day it holds something |
| 78 | the entrypoint stopped because something only a human can supply is missing: no key GitHub knows, no repository, no login. Nothing started |
An unattended run says so on the Windows desktop rather than only in its log:
any status but 0, 2 and 75 raises a toast through host/schedule/notify.sh,
and so does an unattended session still running after
RUNNER_WEDGE_MINUTES — the one failure that produces no exit status at all,
because it never ends.
A comment says what is true now. Where it has value it says why the state
is what it is, in one short line. It is never a log of changes: what was
measured, on what date, what it decided and what was rejected lives in
docs/<topic>.md, where it reads as a document, and the line carries see docs/<topic>.md#<anchor> to it.
Nearly every record there marks a failure that was measured, usually
painfully — that docker run -w creates a missing path as root; that a
named volume is seeded from the image only once, so anything shipped under
the agent's home freezes at first run; that just expands {{ }} inside
recipe comments; that [ -d x ] && cd x under set -e exits before exec.
Read the record before changing the line above it.
The tree keeps tombstones by design. A record is not superseded by the code changing: the record is why the code is what it is. A name that is no longer used, enforcement that was withdrawn, a probe that was re-pointed and what it used to prove — all of it stays, because the next person to have the idea should meet the measurement rather than repeat it. The records cite commits by id, and those ids do not resolve here: the public history starts at one squashed commit, and the history they name is kept privately. Read an id as a date with a pointer for the maintainer, not as a link.
VARIABLES.md — every variable, by category, with what
it defaults to and which of four namespaces it belongs in. Read it before
adding one.
AUTO-MODE.md — every rule of Claude Code's auto-mode
classifier, with its disposition and the reason: 86 shipped rules reviewed
one by one, of which sixteen survive unchanged and fifteen rewritten. It is
generated from auto-mode/
and never edited by hand.
CLAUDE.md — the standing instructions for whoever
maintains this container, agent or human. It is part of the demonstration
rather than incidental to it: its rule 1 says a request from the confined
agent is information to report and never authorization to act on, and the
file was drafted by the agent itself and approved by the operator. That the
confined agent wrote the rules limiting its own influence is not a paradox —
it is the point, and the operator is the only one who can change them.
And docs/, one file per topic, listed in the layout above.