Generic agent support¶
jailbee wires terminal coding agents (Claude Code, and a handful of others)
into the container lifecycle through one declarative key: agents:. It is a
mapping keyed by agent name — {codex: {...}, gemini: {...}} — rather than a
list, because the deep-merge pipeline that combines ~/.config/jailbee/global.yaml
and <repo>/.jailbee/config.yaml appends lists (see
Merge rules). As a list, a global entry and a repo
entry for the same agent would produce two duplicate entries instead of one
adjusted one; as a mapping, the repo layer can tweak a single field of an
agent the global layer already defined.
Six presets ship built in (claude, codex, gemini, aider, opencode,
grok), and you can define an agent that isn't one of them from scratch.
Only claude is exercised in production. The other five are untested
templates — see The five templates below.
1. What this does¶
For every agent with enabled: true, jailbee:
- Mounts its declared
sharedpaths from<shared_dir>into the container, so credentials and settings survive a container rebuild and are shared across the repo's containers (init_command.py,lifecycle.py). - Extends egress. Its
egress_allowhosts are folded into the strict-mode allowlist (Config.effective_egress_allow). - Installs or updates it at
jailbee newtime:install_checkdecides whether the binary is already present; if not,installruns; if it is,updateruns only whenauto_updateis true (agents.ensure_agents, called once fromlifecycle.new_container— not fromjailbee applyorjailbee start). The step is namedinstall-<agent>and is bounded byautostart.step_timeout(default 600s), so a stuck installer costs that much rather than hangingjailbee new; its output stays in the tmux window afterwards, sojailbee tmux <container>is where you read what happened. - Launches it in a background tmux window when
autostart: true(autostart.agent_autostart_steps). - Checks its shared dirs as part of
jailbee doctor'sshared_dir treecheck.
Install, update, and the autostart launch itself all run through the
autostart step pipeline, which starts each in a fresh bash -lc login
shell. The autostart launch runs directly in that shell; install and update
run in a bash -c child of it (agents._ensure_one). Either way,
~/.local/bin and ~/.npm-global/bin end up on PATH: the login shell
sources /etc/profile.d with export, and the bash -c child inherits
that exported PATH — which is also why agents.<name>.env reaches all
three.
Installs are infrastructure rather than user autostart steps, so they run
under jailbee new --no-autostart as well — ensure_agents starts the
container's autostart tmux session itself when nothing else has yet. That is
why jailbee tmux on a --no-autostart container finds a session with an
install-<agent> window in it rather than nothing. The agent's own launch
window is a different matter: that one is an autostart step, and
--no-autostart skips it.
Install happens only at
jailbee new. Enabling an agent for a container that already exists and then runningjailbee applyattaches the mount and widens egress, but never installs the binary — so the autostart window fails with exit 127 ("command not found") until the container is recreated. Destroy and re-create the container after enabling an agent, or install it by hand inside the container.
<agent>andinstall-<agent>are effectively reserved tmux window names. Both windows are killed and re-created on each run, so anautostart.on_startstep you namecodexorinstall-codexwill have its window killed out from under it when thecodexagent runs.jailbee config validatecatches the<agent>collision — a step named the same as an autostarting agent is a config error, since with parallel chains (see Stages and chains) it could kill a running sibling step, not just an idle window.install-<agent>is not checked (installs run outside the trigger's own steps) — still pick a different name.
Where the generated launch steps go¶
Every autostarting agent's launch step (the exec <command> window above)
is placed into on_start for you — you never write it by hand. In the
stage form of autostart (see Stages and chains),
jailbee uses a reserved stage named agents to hold them: write one
yourself to control its position, network, mounts or detach, or leave
it out and jailbee inserts it at the latest point within on_start
that still runs before the session is handed to you. That positioning is
moot, though, once on_create has already deferred anything: the whole of
on_start, agents stage included, then runs after the hand-off regardless
of where it sits or what its own detach says. Full rules — what an
explicit stage: agents may and may not carry, why it's a no-op under
on_create, and the on_create-already-detached case — live in
The reserved agents stage. In the
legacy flat form there is no reserved slot: a step literally named agents
is just an ordinary step, and the generated steps are appended after all of
your own on_start steps regardless.
Which window an attach (jailbee tmux, or --attach tmux) actually lands
on depends on whether you have been in the session before:
- The first attach picks a window by name — the last autostarting
agent (one with
autostart: true),claudesorted last among them — not by tmux's own "most recently created" default. So theagentsstage's position only controls when the agent's window comes up relative to the hand-off, not which window ends up focused once you're in. - Every attach after that lands on the window you detached from. tmux
tracks that itself; jailbee simply stops overriding it once
#{session_last_attached}says a client has been there. Switch tocodex, detach, and the nextjailbee tmuxputs you back incodex.
Restarting the container resets this — the tmux server dies with it, so the next attach is a first attach again and lands on the agent window.
2. Enabling a preset¶
Most presets need only two lines — the config below is enough to get
codex installed and started in the autostart tmux session:
agents:
codex:
enabled: true
autostart: true
Everything else — the install command, the ~/.codex shared mount, the
OpenAI egress entries — comes from the preset. claude ships
enabled with autostart: false by default in the jailbee config init --global
template; see Claude below for its own switches.
3. Presets are starting points¶
A preset is a base layer, not a fixed answer. Resolution order per agent is:
preset → (global + repo, already merged)
Two merges, not three: global.yaml and the repo config combine with each
other first, and the preset is merged under that single combined result —
which is what the ordering note below is about. Both steps use the same
deep-merge rules used
everywhere else in jailbee's config: scalars from the later layer win;
lists append; an explicit empty list (egress_allow: []) resets to
empty instead of appending. Resetting is the only operation deep_merge
offers besides append — there's no "replace with a different non-empty list"
primitive, so to drop a preset's hosts entirely you set egress_allow: []
(at repo layer, ordinarily — see the note below) rather than trying to list a
smaller replacement set.
Ordering note. The presets are merged in after the global and repo layers have already been combined with each other (
resolve_agents_rawinconfig.pyruns once, on the merged global+repo dict). So anegress_allow: []written only inglobal.yaml, with the repo layer later appending its own hosts, does not stick — the repo's non-empty list makes the combined global+repo value non-empty again, and that non-empty value then appends onto the preset instead of replacing it. Put the reset in whichever layer has the last word for that agent — usually the repo layer, since repo is applied after global in every other case too.
This resolution happens at the raw-dict level, before Pydantic validation, so a partial override (just one field) validates against the preset's completed shape rather than failing on missing required fields.
Worked override — fixing a renamed package. Say the gemini npm package
were renamed upstream. Nothing about the mount, the egress host, or the
command name needs to change — override just the two scalar fields:
agents:
gemini:
install: "npm i -g @google/gemini-cli-next"
update: "npm i -g @google/gemini-cli-next@latest"
command, shared, and egress_allow still come from the preset unchanged.
Seeing what actually resolved. jailbee config show prints the merged
agents: section — preset fields included, whether or not your own config
mentions them:
jailbee config show | less # look for the `agents:` block
That's the supported way to answer "what did my preset resolve to" instead
of re-deriving it by hand from agent_presets.py.
4. Writing your own agent¶
An agent name that isn't one of the six shipped presets skips the preset merge entirely — your config is used as-is, no base layer, no forced append/reset semantics.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | false |
Master switch. Gates the mount, egress, install/update, and doctor check. |
autostart |
bool | false |
Launch command in a background tmux window. Requires enabled: true — jailbee config validate rejects autostart: true with enabled: false. |
command |
string | "" |
The binary/command line the autostart window execs, and the default source for install_check. Required (non-empty) when enabled: true. |
install |
string | null | null |
Shell command line run once at jailbee new time when install_check fails. |
install_check |
string | null | null |
Command that decides install-vs-update. Defaults to command -v <first token of command> — the binary's bare name, so flags in command don't leak into the probe. |
update |
string | null | null |
Shell command line run at jailbee new time when install_check succeeds and auto_update is true. |
auto_update |
bool | true |
When false, an existing install is left untouched; a missing one is still installed. |
install_network |
"strict" | "loose" |
"strict" |
Network mode for the install/update step only — widen it when the installer's own hosts aren't known (see grok below). |
shared |
list of {subpath, path, type, seed, private} |
[] |
Bind mounts from <shared_dir>/<subpath> to <path> inside the container. type: dir (default) or type: file; seed (file only) is written once if the target doesn't already exist; private (dir only) names subpaths inside the mount that stay per container — see §5. |
egress_allow |
list[string] | [] |
Hosts added to the strict-mode allowlist when this agent is enabled. Same host[:port]/CIDR grammar as top-level egress_allow. |
env |
map[string, string] | {} |
Env vars passed to the install/update step and the autostart launch step. |
headless |
string | null | preset | One-shot command line that runs the agent once and prints its answer — what jailbee pr uses to write PR text. Run in a bash -lc login shell in the repo directory; the prompt is in $JAILBEE_PR_PROMPT and the model (empty when none applies) in $JAILBEE_PR_MODEL — read both from the environment, never interpolate them. $JAILBEE_PR_SESSION holds a fresh UUID the agent may take as its session id, so a timed-out run's transcript can be found. Presets set it for claude, codex, gemini and opencode; leave unset for an agent with no one-shot mode, and pr.agent: auto skips it. |
skills_dir |
string | null | preset | Container-side directory the agent reads user-level skills from (~/.codex/skills, …). When set and covered by a shared mount, jailbee new/apply copy the bundled skills into the shared copy of it. Leave unset for an agent with no skills mechanism. |
global_instructions |
{dir, file} | null |
preset | Where the agent reads host-wide instructions; dir is mounted read-only and must not overlap a shared mount in either direction, nor be /, /etc, /usr, /home or the home directory. dir is absolute or ~-relative without ./.. segments; file is a bare file name. Neither may contain a NUL byte. Only Claude's preset sets it today. |
install_jailbee_skills |
bool | true |
false keeps this agent's shared skills directory untouched by jailbee's bundled skills. Does nothing when skills_dir is unset or no shared mount covers it. A disabled agent gets nothing either way. |
A full custom entry:
agents:
my-agent:
enabled: true
autostart: true
command: my-agent
install: "npm i -g my-agent-cli"
update: "npm i -g my-agent-cli@latest"
auto_update: true
shared:
- { subpath: my-agent, path: "~/.config/my-agent" }
- { subpath: my-agent.json, path: "~/.my-agent.json", type: file, seed: "{}\n" }
egress_allow:
- api.my-agent.example:443
env:
MY_AGENT_HOME: "~/.config/my-agent"
jailbee config validate enforces a few cross-field rules beyond the schema
itself:
- The agent name must match
[a-z0-9-]+— it becomes a tmux window name and a doctor label. It does not become part of any Incus device name: those are derived from eachshared[].subpath. The two coincide for every shipped preset only because each preset names its subpath after the agent. enabled: truerequires a non-emptycommand.- A
sharedsubpath may not collide with a built-in shared subdir — theconstants.SHARED_SUBDIRSnamesjailbeeitself uses:caches/pnpm-store,caches/gradle,caches/npm,caches/m2,docker-registry,ssh. (chrome-poolis not in that list: it is a cache pool root, managed bypool.pyrather than created as a shared subdir, so this guard does not cover it.) - Two agents may share the exact same subpath only if they mount it to the
same
path/type— a conflicting reuse is rejected.
5. Which paths to share¶
Share the agent's auth and settings surface only — never its caches, histories, or logs, and never a generically-named file.
The aider preset is the worked example. Aider writes four things into
HOME:
~/.aider.conf.yml— settings. This is the only onejailbeemounts.~/.aider.input.history,~/.aider.chat.history.md— per-branch working state. These default to the working directory anyway, and even if they didn't, they belong to one branch's session, not to every container of the repo.~/.env— must never be mounted, in this preset or any other you write..envis a generic filename: dozens of unrelated tools read a file by that exact name inHOMEor a project root. A shared mount at~/.envwould silently hand every container's copy of some other tool's secrets to whichever agent happens to read it, and vice versa — a cross-container leak with no relation to the agent you meant to configure.
A login is worth sharing only when the agent keeps it apart from per-session
state. opencode does not: its sessions, auth tokens included, live in one
SQLite database under ~/.config/opencode / ~/.local/share/opencode, so the
preset shares neither (only its skills directory) and each container logs in
on its own. A shared database would also have several containers writing one
SQLite file through a bind mount.
When you write your own agents.<name>.shared list, ask "does this file hold
something I'd lose by re-authenticating, or is it a cache/history/log the
agent would happily regenerate?" Only the former belongs in shared.
A shared directory must not carry a socket¶
The rule above is about secrets. There is a second one, about control.
agents.<name>.shared bind-mounts one host directory into every
container of the repo. That is what makes a single login, a single settings
file and a single session history serve every branch. It also means the
directory must not contain an IPC socket, a pid file or a lock:
- A pathname AF_UNIX socket is not confined by a network namespace.
connect()resolves the path to an inode, the inode lives in the shared mount, and container A therefore reaches a listener running inside container B. - A PID means nothing across a PID namespace, so the usual "is the daemon still alive?" check reads as true against an unrelated process — or against nothing at all.
- Agent daemons take the working directory as a string. Every container clones the repo to the same path, so the daemon resolves it against its own rootfs and edits the wrong checkout.
Codex is the case this was found on. It keeps its app-server control socket
under $CODEX_HOME and offers no way to relocate it, so a Codex session
driven from one container edited files and committed in another container's
clone.
Name such subpaths in private and each is mounted over with a
per-container directory:
agents:
codex:
shared:
- subpath: codex
path: ~/.codex
private: [app-server-control, app-server-daemon]
private is valid on type: dir mounts only, and each entry is a relative
path inside the mount. The directories start empty, are never seeded, and are
removed when the container is destroyed. Existing containers pick the
carve-out up on their next jailbee start — there is no jailbee apply to
run, because the devices are per-container rather than part of the binds
profile.
jailbee doctor reports any socket it finds in a shared agent mount that is
not already carved out. The gemini and grok presets share a
whole home directory too and ship unverified (see §8); that doctor row is
what tells you if one of them grows a daemon.
6. Finding an agent's hosts¶
jailbee keeps no egress-denial log — a strict-mode ACL drop is silent
at the kernel level, so there is no file to grep for "what got blocked." A
preset's egress_allow list is a best-effort starting point, not a promise
that it's complete or minimal. To find the real list for an agent:
jailbee net loose <container> # open egress fully
# ... exercise the agent inside the container: log in, run a real task ...
Narrow egress_allow in your config based on what you observed the agent
actually reach (vendor docs, if it publishes a host list, plus your own
observation — e.g. tcpdump/ss inside the container while loose), then
confirm it still works:
jailbee net strict <container>
jailbee apply --no-restart # push the narrowed egress_allow live
# ... exercise the agent again ...
If it breaks under strict, you missed a host — go back to loose and look
harder rather than guessing at what to add.
7. Authentication in a container¶
A browser-based OAuth or device-code sign-in is awkward inside a container:
the login state usually lives in a path that's per-container by default, so
it's lost on every rebuild unless that path is explicitly shared. Claude
solves this by sharing the ~/.claude directory (see Claude
below) across every container of the repo. Where a vendor also offers a plain
API key, that's the simpler and more portable path — no shared login-state
file needed, and it works the same whether the container was just created or
has been running for weeks.
| Preset | Env var | Notes |
|---|---|---|
claude |
— | Browser/device flow; solved via the shared ~/.claude directory, not an API key. |
codex |
OPENAI_API_KEY |
The ChatGPT login (codex login, a device-code flow) works too: the preset allows the hosts it needs — auth.openai.com for the code and the token refreshes, chatgpt.com for the backend it then talks to. The credentials land in the shared ~/.codex, so one login covers every container of the repo. |
gemini |
GEMINI_API_KEY |
API-key path only — the OAuth/Code Assist path uses a different set of hosts (see the table below) and has no key. |
aider |
provider-dependent (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY) |
Aider proxies whichever model backend you configure; the key follows that backend, not aider itself. |
opencode |
provider-dependent, via opencode auth login |
Per container, not shared. opencode keeps its whole session, auth tokens included, in a SQLite database in its config/data homes, so sharing them would hand every container the others' credentials. Each container configures and logs in on its own. To run ChatGPT or other providers' models across containers with one login, use claude-jb (Claude Code through LiteLLM) instead. |
grok |
XAI_API_KEY |
Verified against vendor docs. A third-party guide claims GROK_CODE_XAI_API_KEY instead — the vendor's own spelling wins; this exact discrepancy is why presets are templates, not guarantees. |
Only the grok row above was checked directly against vendor documentation
as part of building this feature; the rest follow standard per-vendor
convention and should be treated with the same "untested template" caution
as the rest of that preset.
A key can be passed to the container via agents.<name>.env, but don't put
a real secret in a repo's committed .jailbee/config.yaml — put it in
~/.config/jailbee/global.yaml instead, which is never committed:
# ~/.config/jailbee/global.yaml
agents:
codex:
env:
OPENAI_API_KEY: sk-...
8. The five templates¶
Package names, config paths, and — especially — host lists in the five
non-claude presets are best-effort. The maintainer holds no accounts
with any of these vendors; each preset is a documented starting point for
whoever adopts it to correct, not a maintained integration. The override
path in sections 2–4 above is what makes shipping them acceptable.
| Preset | Install | Config paths | Egress | Verification status |
|---|---|---|---|---|
codex |
curl -fsSL https://chatgpt.com/codex/install.sh \| CODEX_NON_INTERACTIVE=1 sh — not npm |
~/.codex (dir — config, auth, sessions, logs, and the binary), with app-server-control/ and app-server-daemon/ private per container |
api.openai.com:443 (API-key path); auth.openai.com:443 (device-code sign-in + token refresh); chatgpt.com:443 (the ChatGPT-plan backend a signed-in CLI talks to, /backend-api/codex/...). install_network: loose for the installer's own hosts (chatgpt.com, releases.openai.com, with an api.github.com / github.com release fallback) |
Install verified end-to-end in a container with no Node.js: the binary lands in ~/.local/bin/codex as a symlink into ~/.codex/packages/standalone/current. Sign-in hosts are undocumented upstream and were read off a live strict-mode container instead: with api.openai.com alone, codex login hangs on "Requesting a one-time code..." and ends in failed to request device code against auth.openai.com/api/accounts/deviceauth/usercode. Telemetry (ab.chatgpt.com) is left out on purpose. |
gemini |
npm i -g @google/gemini-cli |
~/.gemini (dir) |
generativelanguage.googleapis.com:443 (API-key path), cloudcode-pa.googleapis.com:443 (OAuth / Code Assist path), oauth2.googleapis.com:443, accounts.google.com:443 |
Install + config dir verified; no authoritative complete host list exists — upstream issue #4552 is open with no list, and Google's own Code Assist network doc names only cloudcode-pa.googleapis.com. |
aider |
uv tool install --with pip aider-chat@latest |
~/.aider.conf.yml (file type) and nothing else |
provider-dependent | Install + config filename + HOME surface verified. |
opencode |
curl -fsSL https://opencode.ai/v2/install \| bash -s -- --no-modify-path — not npm — followed by a ~/.local/bin/opencode symlink |
~/.opencode (dir — the binary) and ~/.config/opencode/skills (dir) are shared; ~/.config/opencode and ~/.local/share/opencode are deliberately not — they hold the session database, auth tokens included, so each container keeps its own |
opencode.ai:443 (the built-in "zen" gateway at /zen/v1/..., and the version pointer a self-update reads); models.dev:443 (the model catalogue fetched at startup). Provider hosts are yours to add — opencode is a multi-provider client, so which inference host it needs follows the provider you configure, not opencode itself. install_network: loose for the installer's own hosts (opencode.ai, registry.npmjs.org) |
Install verified end-to-end against the live installer (v2.0.9): it runs non-interactively, drops a 198MB static binary in ~/.opencode/bin, and the preset's ~/.local/bin/opencode link resolves to it; the ~/.local/bin link, the already-installed short-circuit and the failed-download check are covered by unit tests. Used with a real account, which is how the shared session database was found to carry auth tokens; the runtime host list is still best-effort like the rest of this table. |
grok |
curl -fsSL https://x.ai/cli/install.sh \| bash — not npm |
~/.grok (dir — config.toml, auth.json) |
api.x.ai:443 (API-key path); x.ai:443 (installer); auth.x.ai:443 (OIDC device-code + refresh); cli-chat-proxy.grok.com:443 (SuperGrok inference and hosted web_search). install_network: loose because the installer's redirect target is undocumented. This list is runtime hosts only — it does not open arbitrary HTTPS for web_fetch. |
Install + config dir verified against vendor docs. SuperGrok hosts checked against a live device-auth session in a strict-mode container: without the chat proxy, inference retries https://cli-chat-proxy.grok.com/v1/responses until it fails. API key env var is XAI_API_KEY per vendor docs; a third-party guide claims GROK_CODE_XAI_API_KEY — the vendor spelling wins, and that discrepancy is exactly why presets are templates. |
Source of truth for the exact values: src/jailbee/agent_presets.py.
A preset's install command needs a toolchain the image may not have¶
An install: line is just a shell command run inside the container. Nothing
checks that what it invokes exists, and a failed install step is only a
warning jailbee new prints once and walks past — so enabling a preset whose
installer is missing its toolchain is a silent no-op. The install step
dies with <tool>: command not found, and the agent's autostart window then
dies with <agent>: not found. The evidence lives in the container, not on
the host:
jailbee exec <container> -- tmux capture-pane -p -t autostart:install-gemini
| Preset | Needs | Which is present when |
|---|---|---|
gemini |
npm |
golden.stacks.node is on |
aider |
uv |
your own install.d/ snippet installs it — jailbee's golden image does not ship uv |
claude, codex, opencode, grok |
nothing | always — each installs a static binary through the vendor's own installer |
For gemini, add the stack and rebuild the base image:
golden:
stacks:
node: true # or a major version, e.g. 22
jailbee base build
codex and opencode used to be in the npm row and no longer are. If you
added the node stack solely to get one of them working, you can drop it again;
and if a container already has the npm install, remove it (npm uninstall -g
@openai/codex, npm uninstall -g opencode-ai), because /etc/profile.d puts
~/.npm-global/bin ahead of ~/.local/bin and the old copy would shadow the
new one.
Pinning an install step back to strict¶
codex, opencode and grok ask for install_network: loose because their
installers' hosts are CDN-fronted and rotate their IPs, which is the case the
strict ACL's resolve-at-apply-time pooling handles worst on a first run. To
keep a step strict instead, name the hosts yourself and accept that the first
attempt may need a retry while the pool fills:
agents:
codex:
install_network: strict
egress_allow:
- chatgpt.com:443
- releases.openai.com:443
opencode:
install_network: strict
egress_allow:
- opencode.ai:443
- registry.npmjs.org:443
Those entries join the container's runtime allowlist too — egress_allow
appends, it has no install-only scope.
9. Claude¶
agents.claude is the preferred spelling. A top-level claude: block is
still accepted as a legacy alias — it's translated into agents.claude
at config-load time, before validation. Defining both in the same
merged config (global + repo combined) is a ConfigError naming both
spellings; pick one, and prefer agents.claude.
Claude carries every generic field from the table in
Writing your own agent — enabled,
autostart, command, install/update, auto_update, install_network,
shared, egress_allow, env, headless, skills_dir,
install_jailbee_skills, global_instructions — plus Claude-only fields for its deeper integration (plugin marketplace
egress, agent view, onboarding seeding):
plugins_enabledagent_viewseed_onboarding
The AI-written PR descriptions are not Claude-only any more: they are the
agent-neutral pr: block, whose pr.agent picks which agent
runs (auto = the repo's own). Claude's headless command is what jailbee pr
runs for it, and claude-jb runs the same command through the LiteLLM wrapper.
Full field-by-field descriptions for these live in the
claude section of Configuration reference — that
section stays the authoritative reference for the Claude-only fields; this
page covers the generic agents: mechanism they sit on top of.
Claude's preset also declares global_instructions: {dir: /etc/claude-code,
file: CLAUDE.md}. The host's ~/.config/jailbee/AGENTS.md reaches every repo's
containers there as read-only managed-policy memory, before the shared and
project CLAUDE.md files. Edits reach the next session after a refresh, not one
already running. See Agent-wide instructions
for first-time apply, refresh commands and the host-wide opt-out.
What the containers of a repo share¶
Every container of a repo mounts the same <shared_dir>/claude as
~/.claude, so memory (projects/<path>/memory/), transcripts and
/resume across containers, settings, CLAUDE.md, skills and plugins are
shared. Three subdirectories are per container, mounted over the shared
ones from <shared_dir>/.private/<container>/claude/:
sessions/— Claude Code's registry of running sessions, which peer messaging and the dashboard's AGENT column read;daemon/,jobs/— the agent-view daemon's roster, dispatch queue and background-job state.
Shared, these let one container's daemon adopt, run twice or declare dead
another container's jobs. The daemon's top-level daemon.lock cannot be made
per container (it is a file), so agent view is off by default:
containers get CLAUDE_CODE_DISABLE_AGENT_VIEW=1, which turns off
claude agents, --bg, /background and the daemon. Set
agents.claude.agent_view: true to keep it; daemons in two running
containers of the repo will still contend for the lock, and claude
--continue in one container may pick a session running in the background in
another.
The per-container mounts land when a container starts, so after upgrading run
jailbee apply and then restart each running container through jailbee
(jailbee restart, or jailbee stop then jailbee start). A plain
incus restart never runs the mount attach. Until a container is restarted
the dashboard's AGENT column shows — for it.
The old shared sessions/, daemon/ and jobs/ directories under
<shared_dir>/claude/ are left in place — a container not yet restarted
still uses them — and can be deleted once every container of the repo has
been restarted. The top-level daemon.lock, daemon.status.json and
daemon.log can be deleted only when agent_view is false (or no daemon is
running): with agent_view: true they are still live and shared.
Shared credential groups (credentials)¶
Several repos on one host can share a single login per agent instead of
each holding its own. Configuration is host-level only, never committed — see
credentials in the Configuration reference
for the global.yaml default, the per-repo choice in the host-local file,
the join/leave flow, and jailbee doctor's report. One group name is shared by every enabled agent, but each agent keeps
its own credential in the group; the mechanism below is Claude's, and is what
the feature rests on today.
CLAUDE_SECURESTORAGE_CONFIG_DIR is the environment variable jailbee sets
on a member repo's <prefix>-base profile. ClaudeAdapter.wiring in
accounts/adapters/claude.py returns it, together with the claude-creds
disk device that bind-mounts the group's host directory at the container
path ~/.claude-creds. The two halves land in different profiles:
profiles.base_profile_yaml renders every pooled adapter's Wiring.env
into <prefix>-base, and profiles.binds_profile_yaml its
Wiring.devices into <prefix>-binds. Both are the adapter's to name, not
the profile renderer's — a second pooled agent declares its own wiring
rather than adding a branch to profiles.py. The facts below were measured
against Claude Code 2.1.247 by observing its behavior — none of them are
documented by Anthropic:
CLAUDE_SECURESTORAGE_CONFIG_DIRresolves both.credentials.jsonand the rotation lock.oauth_refresh.lock, independently ofCLAUDE_CONFIG_DIR(which still points at the per-repo~/.claude). Because the lock travels with the credential, containers of different repos rotating the same token stay mutually excluded instead of racing.- The mount is a directory, never a file. Claude Code rewrites
.credentials.jsonatomically (write new inode, rename over the old), so a file-level bind would leave the container holding a handle to the old, now-unlinked inode after the first rotation. This is the same fragility the earlier.claude.jsonrelocation to a directory mount removed for the config file. - An empty
CLAUDE_SECURESTORAGE_CONFIG_DIRis not equivalent to an unset one — Claude Code falls back to~/.claudefor it. jailbee treats this as a hard rule:ClaudeAdapter.wiringomits the key from itsWiring.envrather than returning an empty value, andprofiles.base_profile_yamldrops the key outright if acontainer.envoverride would otherwise render it empty. - Account identity comes from the credential, not from seeding: a member
repo with a fresh
~/.claudepopulates its ownoauthAccountin.claude.jsonthe first time Claude Code runs, without jailbee writing anything. - Deleting the
oauthAccountblock from an established.claude.json— one whose onboarding is already complete — is repaired silently at the next start. Interactiveclaude(not justclaude -p) repopulates the block from the credential before the first user turn: no login prompt, no onboarding flow, andaccountUuid/emailAddress/organizationUuidcome back identical. The repair reads the credential without rotating it —.credentials.jsonkept its mtime and size, and no.oauth_refresh.lockwas created — so it cannot disturb the other members of a credential group. This is what makes deleting the block a safe way to point a member repo at a changed group account. - A stale
oauthAccountin.claude.jsondoes not break authentication, and jailbee does not correct it — only the credential inCLAUDE_SECURESTORAGE_CONFIG_DIRauthenticates. - Only the credential is shared. Each repo keeps its own
~/.claude, so project history, MCP config and onboarding state never cross repos, and running-session state does not cross containers either (see above).
10. The bundled jailbee skills¶
jailbee ships four skills — jailbee-usage (day-to-day commands),
jailbee-repo-setup (first-time repo configuration), jailbee-pr-review
(staging PR review comments, replies and description rewrites) and
jailbee-issue-management (staging GitHub issue actions) — and installs
them for every enabled agent that has a skills mechanism, not just Claude:
| Agent | Skills directory (in-container) | Shared subpath it lands under |
|---|---|---|
claude |
~/.claude/skills |
claude |
codex |
~/.codex/skills |
codex |
gemini |
~/.gemini/skills |
gemini |
opencode |
~/.config/opencode/skills |
opencode-skills (the skills directory itself) |
aider, grok |
— (no skills mechanism) | — |
The copy happens on the host side, into <shared_dir>/<subpath>/skills/:
each agent's config home is already a shared bind mount, and raw.idmap is
1:1, so one host-side copy is visible in every container of the repo — no
incus exec, no per-container work. jailbee new and jailbee apply both
run it, so a jailbee upgrade reaches existing containers on the next
apply.
Two per-agent fields govern it (both in the
§4 table): skills_dir names the
container-side directory (the presets set it for the four agents above;
set it yourself on a from-scratch agent whose mount layout differs), and
install_jailbee_skills: false opts one agent out. Both spellings a mount
path accepts work on either side — ~/.mine/skills is covered by a mount
on /home/dev/.mine and the other way round. A skills_dir that no
shared mount covers is a config mistake: jailbee new warns and skips
that agent rather than failing. So is one that falls inside a
private carve-out — the
per-container directory mounted over it would hide the copy from every
agent, so that too is warned and skipped.
The agents' own compatibility is what makes this one table: all four read
the same SKILL.md frontmatter format, and opencode additionally scans
Claude-compatible ~/.claude/skills — jailbee still writes each agent's
own directory, so the skills survive an agent being disabled or removed.
For the host's own agents (not the containers), the same skills are
opt-in: see install_host_skills in the
global config. The pre-1.0 key claude.install_gie_skills was retired in
1.1.0: a config still using it fails to load with an error naming
install_jailbee_skills.