From claude-code-hermit
Generates Docker scaffolding for containerized autonomous agent deployment. Run after /hatch to set up token, build, start, MCP config, workspace trust, and verification.
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-code-hermit:docker-setupThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Generate Docker scaffolding for running hermit as an always-on autonomous agent in a container. Docker provides isolation, crash recovery, and a reproducible environment. The default `auto` mode (classifier-reviewed autonomy) works well for most Docker hermits. Operators who need zero prompts for fully unattended operation can opt into `bypassPermissions` via `/hermit-settings permissions`.
Generate Docker scaffolding for running hermit as an always-on autonomous agent in a container. Docker provides isolation, crash recovery, and a reproducible environment. The default auto mode (classifier-reviewed autonomy) works well for most Docker hermits. Operators who need zero prompts for fully unattended operation can opt into bypassPermissions via /hermit-settings permissions.
Tone: Friendly guided wizard. Celebrate progress. When something fails, help fix it.
Important: Step 0's container check is a hard short-circuit — run it first and abort on container before anything else. After confirming host execution, run docker-preflight.ts once (Step 1) — it gathers all the read-only signals (docker presence, config existence, WSL path, existing docker files, host ~/.gitconfig, auto-memory seed) as a single JSON blob, so don't fan those probes out into separate Bash calls. Reuse its result for the git-identity (Step 4) and auto-memory (Step 5) decisions rather than re-probing. Step 2's project-dependency scan stays normal file reads (Read tool) plus analysis. Everything that mutates state or drives the container (file backups, docker compose, tmux, mkdir/touch setup-mode, status polls, channel send-keys) must run strictly sequentially in its documented order — never batch those.
Templates live in ${CLAUDE_SKILL_DIR}/../../state-templates/docker/.
This skill is host-only — it generates Docker scaffolding on the host filesystem and drives docker compose up.
Run: [ -f /.dockerenv ] || [ -f /run/.containerenv ] && echo container || echo host
If the output is container, stop immediately — do not proceed to step 1. Print:
This skill generates host-side Docker scaffolding and then drives
docker compose up. Run it from your host shell in the project root. To check what's already configured inside the running container, run/claude-code-hermit:hermit-doctor.
Run the pre-flight probe once and parse its JSON: bun ${CLAUDE_PLUGIN_ROOT}/scripts/docker-preflight.ts "$(pwd)" — pass "$(pwd)" so the auto-memory path key is keyed off the shell's logical path, matching Claude Code even through a symlinked project root. It returns { dockerVersion, configExists, isWSL, existing: {dockerfile, entrypoint, compose}, gitconfigExists, memory: {pathKey, seedExists} }. Hold the result — Steps 4 and 5 reuse gitconfigExists and memory instead of re-probing. Apply these gates:
dockerVersion is null: "Docker isn't installed — grab it from https://docs.docker.com/get-docker/ and come back!"configExists is false: "Run /claude-code-hermit:hatch first, then come back."isWSL is true: abort — "Clone inside WSL2 (e.g. /home/you/project) and run from there."existing.dockerfile / existing.entrypoint / existing.compose is true:
AskUserQuestion (header: "Docker files"): No — keep existing (abort; remove or rename manually, then re-run) / Yes — back up (move to docker-backup/ and regenerate).docker-backup/, continue.env — only append missing vars (step 5).Determine whether to run in Quick or Advanced mode.
Argument-driven: if invoked with the positional argument quick (e.g. /claude-code-hermit:docker-setup quick), skip the question below and run Quick directly. This is what hatch chains to at the end of a Quick hatch run. The operator can always re-invoke /claude-code-hermit:docker-setup without the arg later to drop into Advanced.
Otherwise, ask:
questions: [
{
header: "Setup mode",
question: "How would you like to configure Docker?",
options: [
{ label: "Quick", description: "OAuth + bridge networking, auto-mirror trusted plugins, build immediately" },
{ label: "Advanced", description: "Full wizard — pick auth, networking, every plugin, every package" }
]
}
]
Quick-mode contract — security non-negotiables. Quick mode never bulk-accepts third-party plugins, never weakens the safelist, never skips the public-repo pre-flight, and never bypasses the write-time assertion. Those gates exist because that's the line where defaults stop being safe. Quick only auto-defaults the choices that have one obviously-correct answer (auth, networking, build-now) and the SAFE plugin batch (claude-plugins-official + gtapps/* — already vetted by the safelist).
Branch on the choice for the rest of the skill:
Quick: silently apply auth = oauth, docker.network_mode = "bridge". Skip the AskUserQuestion below. Continue at the project-dependencies scan below.
Advanced: ask auth method and networking in a single AskUserQuestion call:
questions: [
{
header: "Auth",
question: "Authentication method for the container?",
options: [
{ label: "OAuth", description: "Run claude /login inside the container — recommended" },
{ label: "API Key", description: "Set ANTHROPIC_API_KEY in .env" }
],
},
{
header: "Networking",
question: "Container network mode?",
options: [
{ label: "Bridge", description: "Isolated from host-local services (default)" },
{ label: "Host", description: "Direct access to localhost services — use if hermit needs host-bound services" }
]
}
]
Record networking choice as docker.network_mode ("bridge" or "host").
Project dependencies scan: Scan for signals that suggest extra system packages:
package.json native addons (sqlite3, sharp, canvas, bcrypt, etc.)requirements.txt, pyproject.toml, Pipfile)Makefile, CMakeLists.txt), version managers (.tool-versions).claude/settings.json and .claude/settings.local.json permissions.allow entries — allowed Bash commands reveal tools the project expects (e.g. Bash(docker *) → docker CLI, Bash(python *) → python3, Bash(psql *) → postgresql-client). Check for commands that need system packages not in the base image.Present findings conversationally with reasoning. If nothing found, say so. Collect as provisional candidates — confirmation is deferred to step 7b.packages after plugin selection, where plugin-declared deps are unioned in.
Read .claude-code-hermit/config.json and extract:
tmux_session_name — resolve {project_name} with actual directory nameagent_namepwdDo NOT set AGENT_HOOK_PROFILE in config.json env — it stays as standard (the host default). The strict profile is rendered into the docker-compose environment block at Step 7b.6 via the agentHookProfile render input.
Execution moved.
scripts/render-docker-templates.tsrenders the three base files at Step 7b.6 — AFTER plugin resolution (Step 7b) and apt-package union (Step 7b.packages) have finalizeddocker.packages, and Step 7 has configured channels. Rendering earlier consumed an empty package list. Do NOT read or write template files here; just collect the semantic inputs the render call needs.
The render script derives every {{PLACEHOLDER}} internally and fails loud (writes nothing) if any survives. Assemble this input object as choices resolve, to be piped on stdin at Step 7b.6:
packages — the finalized docker.packages array (Step 7b.packages). Empty array → no project-package layer.auth — "api-key" or "oauth-token" (Step 2). OAuth adds no auth env line; credentials live in .credentials.json in the named volume, written by claude /login.channels — { envLines: [...], volumeLines: [...] }, one entry per enabled channel (Step 7). Pass the line bodies (the script owns the - indent):
<VAR>_STATE_DIR=${PWD}/.claude.local/channels/<plugin> (VAR = DISCORD / TELEGRAM)${PWD}/.claude.local/channels/<plugin>:/home/claude/.claude/channels/<plugin> (keeps channel writes inside the project tree — no permission prompts under bypassPermissions)agentHookProfile — always "strict" for Docker (enforces always_on deny patterns inside the container).tmuxSessionName — the session name resolved in Step 3.networkMode — docker.network_mode ("bridge" or "host", Step 2).gitIdentityMount — the Step 1 preflight gitconfigExists. When false, the .gitconfig bind-mount is dropped and you must add to the summary: "No ~/.gitconfig found — git commits inside the container will have no author identity. Create one on the host and re-run docker-setup, or set git config manually inside the container."The Step 1 preflight already resolved memory.pathKey (pwd | sed 's|/|-|g' — keeps leading dash, matches Claude Code's format, e.g. -home-user-myproject) and memory.seedExists (whether ~/.claude/projects/<pathKey>/memory/MEMORY.md exists). Reuse those:
memory.seedExists is true: Copy ~/.claude/projects/<memory.pathKey>/memory/MEMORY.md to .claude-code-hermit/MEMORY-SEED.md. Tell the operator: "Found existing Claude Code memory for this project — seeding it into the container so your hermit starts with full context." Then ensure .claude-code-hermit/MEMORY-SEED.md is in .gitignore (append if missing).Only the top-level project memory is seeded — not agent-scoped memories at <path-key>/<agent-name>/.
If .env doesn't exist, create it. If it does, read it first.
If apikey: Check if ANTHROPIC_API_KEY= is present in .env. If missing, append:
# --- claude-code-hermit ---
ANTHROPIC_API_KEY=your-api-key-here
If already present, leave it — note for step 8.
If oauth: No auth var needed in .env. Check whether ANTHROPIC_API_KEY is set (non-empty) in .env. If so, warn and ask with AskUserQuestion (header: "API key"): Yes — comment out (prefix with # to disable it) / No — keep (container will run in API key mode).
Also check for and offer to remove any CLAUDE_CODE_OAUTH_TOKEN — this env var is not used in the new flow.
Ensure .env is listed in both .gitignore and .dockerignore (create the files if needed, append if missing).
Deny patterns: Merge the default deny set into the target settings file.
Resolve hatch_target using the same fallback chain as hermit-evolve SKILL.md §2a (.claude-code-hermit/state/hatch-options.json "target" field → marker scan of CLAUDE.local.md / CLAUDE.md → claude plugin list --json scope detection). Do not silently default to committed when the state file is absent — that can leak operator-personal hardening into the repo. Map: hatch_target == "local" → .claude/settings.local.json; hatch_target == "committed" → .claude/settings.json.
Run: bun ${CLAUDE_PLUGIN_ROOT}/scripts/apply-settings.ts <resolved-settings-file> deny minimal
(Reads canonical default set from state-templates/deny-patterns.json; never removes existing entries. Does NOT include always_on patterns — docker/ssh/kubectl are valid for later steps and are enforced at runtime by the hook under strict profile.)
Tell the operator: "Added safety deny rules for always-on operation — protects against destructive commands and credential exposure. Container-specific rules (docker, ssh, kubectl) are enforced by the hook at runtime. Written to {.claude/settings.local.json | .claude/settings.json}."
Sandbox note: For container deployments the Claude Code bash sandbox is set off (sandbox.enabled:false) at /hatch time. The container boundary (cap_drop:ALL, no-new-privileges, network policy) is the isolation layer here, per Anthropic recommendation for unattended runs (https://code.claude.com/docs/en/sandbox-environments), so the nested bwrap sandbox is optional rather than required. It is left off because on Ubuntu 24.04+/26.04 hosts (and any host with kernel.apparmor_restrict_unprivileged_userns=1) the AppArmor docker-default policy blocks bwrap from creating user-namespaces in-container, so it can't start and would kill heartbeat and /watch monitors. Note: sandbox.enableWeakerNestedSandbox does NOT work around a blocked user-namespace — it only helps with the /proc access case. hermit-start does not change this key at runtime; if an existing install still has sandbox.enabled:true, /hermit-evolve will offer to set it off.
Skip if no enabled channels in config.json. Read .claude-code-hermit/config.json from the project root (same root verified in Step 1 — do not rely on cwd). A channel counts as present when config.channels[<name>].enabled !== false (missing enabled is treated as enabled). Empty channels: {} skips.
Token locations in Docker: Always use .claude.local/channels/<plugin>/.env (project-local scope). Never read or copy from global ~/.claude/channels/.
| Plugin | Token var | Docker compatible |
|---|---|---|
| discord | DISCORD_BOT_TOKEN | Yes |
| telegram | TELEGRAM_BOT_TOKEN | Yes |
| imessage | — | No (macOS only) |
If iMessage detected, note it won't work in Docker and skip.
For each configured channel:
claude plugin install <plugin>@claude-plugins-official --scope localchannels.<channel>.state_dir as a relative path (e.g. .claude.local/channels/discord). hermit-start resolves it against the project root at boot and derives the *_STATE_DIR env var — no need to add it to env. Absolute paths also work..claude.local/channels/<plugin>/.env):
AskUserQuestion (header: "Bot token"): Skip (write the token to .claude.local/channels/{channel}/.env later and restart) / Enter now (paste token via Other).
mkdir -p, write .env, chmod 600, ensure .claude.local/ in .gitignore, clean stale token from .claude/settings.local.json env if present. Confirm saved..claude.local/channels/<plugin>/.env later and restart."Mirror host-installed plugins into the container so the container starts with the same plugin set the operator already curated — including any domain hermit (e.g. claude-code-homeassistant-hermit) that may have triggered this setup flow.
SECURITY-CRITICAL STEP. Every plugin written here is auto-installed on container boot with whatever
permission_modethe operator chose — forbypassPermissionshermits, this means full unrestricted execution. Do not shortcut the safelist, validation, or deselection rules below. If you find yourself about to skip one, stop and re-read this section.
Run bun ${CLAUDE_PLUGIN_ROOT}/scripts/resolve-siblings.ts "$(pwd)" --dedupe to enumerate plugins. It emits a JSON array of the project-or-local + enabled entries (each carrying plugin, id, marketplace_name, scope, installPath), with user-scope, managed, disabled, and cross-project entries already dropped, and deduped by (plugin, marketplace_name) keeping the local entry over project (local is the override scope in Claude Code's overlay model). Different marketplaces remain distinct entries.
If the operator wants a user-scope plugin in the container, they install it at project scope on the host first and re-run this skill.
Filter out additionally:
claude-code-hermit@claude-code-hermit — the entrypoint already handles it unconditionally.config.channels (they flow through the entrypoint's channel branch).Run claude plugin marketplace list --json and build a marketplace_name → repo map from each entry's name and repo fields. Split each plugin id as <plugin>@<marketplace_name> and look up marketplace_name in the map to get the org/repo. Only the org/repo is recorded on each docker.recommended_plugins entry — the entrypoint resolves the canonical marketplace name at boot via the same marketplace list --json lookup, so storing it twice would just duplicate the source of truth.
source != "github" or no repo field (e.g. a locally-added marketplace), ask: "What's the GitHub source for the <marketplace_name> marketplace? (e.g. org/repo)" before presenting the plugin list.Validation gate — apply to every org/repo before it reaches the plugin list or config.json:
^[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*$repo values read from the marketplace JSON AND values typed by the operator.config.json and being passed to claude plugin marketplace add on boot.Partition the filtered list using the safelist:
def is_safelisted(marketplace):
# marketplace is always org/repo after the step 7b.3 lookup — no literal-name shortcut.
return (
marketplace == "anthropics/claude-plugins-official"
or marketplace.startswith("gtapps/")
)
The marketplace value passed to is_safelisted is always the org/repo resolved in step 7b.3 — never the marketplace name and never the slug from the <plugin>@<name> id. If you're about to pass claude-code-homeassistant-hermit (a slug) to is_safelisted, you skipped the lookup — go back to step 3.
Split into two groups:
is_safelisted(marketplace) is True. Examples: anthropics/claude-plugins-official, gtapps/claude-code-homeassistant-hermit.obra/superpowers-marketplace, any non-gtapps org/repo, any locally-added marketplace without a GitHub source.The SAFE group can be accepted as a batch. The THIRD-PARTY group must be confirmed one-by-one. Do not bulk-accept third-party plugins for the operator's convenience — the host list is the candidate set, not the trusted set.
Present the choices in plain text first, so the operator sees the full list before any prompt (AskUserQuestion caps options at 4, so we cannot enumerate plugins in a single question):
Host-installed plugins detected (project + local scope only):
Safelisted (trusted sources):
- claude-code-setup @ anthropics/claude-plugins-official (project)
- claude-code-homeassistant-hermit @ gtapps/claude-code-homeassistant-hermit (project)
Third-party (require individual opt-in):
- superpowers @ obra/superpowers-marketplace (local)
If the SAFE group is non-empty:
Quick: silently treat as if "Mirror all" was chosen — add every SAFE plugin to docker.recommended_plugins with enabled: true. No prompt.
Advanced: ask once with AskUserQuestion (header: "Plugins"):
"Mirror all" (Recommended) / "Pick each" / "Skip all".Mirror all: add every SAFE plugin to docker.recommended_plugins with enabled: true.Pick each: loop through the SAFE plugins; for each, ask a 2-option yes/no ("Include" / "Skip"). Only include the ones the operator confirms.Skip all: add nothing from this group.For the THIRD-PARTY group, loop through each plugin individually. For each, ask a 2-option AskUserQuestion (header: "Third-party") — "Include" / "Skip". Include only when the operator explicitly confirms. Never batch-accept this group.
8b. Public-repo pre-flight (for every non-claude-plugins-official entry that the operator has tentatively confirmed in steps 7 or 8). From the host, run an unauthenticated check against https://github.com/<org>/<repo> (e.g. curl -fsI -o /dev/null -w '%{http_code}' https://github.com/<org>/<repo>). If the response is not 200 (i.e. 404, redirect-to-login, or network error), the repo is private or unreachable. Tell the operator:
<org>/<repo>appears private or unreachable. The container cannot clone private repos automatically.
Then ask with AskUserQuestion (header: "Private repo") — "Include anyway" / "Skip". On Skip: drop the entry. On Include anyway: keep it. Do not suggest remediations.
After both groups are processed: any plugin not explicitly confirmed must not be written to docker.recommended_plugins. Write only confirmed entries with enabled: true.
Write-time assertion — run this before handing entries to step 7c. For each confirmed entry:
marketplace matches ^[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*$ (has a slash; is org/repo). Uniform across official and third-party entries — official stores anthropics/claude-plugins-official, no more literal-name shortcut.org/repo from the claude plugin marketplace list --json output (look up the marketplace name from the plugin's id to get the repo). If the lookup cannot produce a valid org/repo, prompt the operator (same prompt as step 3). Re-run the assertion. Only proceed to step 7c once every entry passes.If the filtered list is empty (no extras installed on the host), skip silently — no prompt, no entries written.
Record the confirmed selection as docker.recommended_plugins. Each entry has:
{"plugin": "<plugin-name>", "marketplace": "<org/repo>", "scope": "<scope>", "enabled": true}
The entrypoint resolves the canonical marketplace name at boot via claude plugin marketplace list --json (no need to store it on the entry), adds the marketplace if missing, and installs every enabled entry on first boot. See Recommended Plugins for the full policy.
Legacy config note. Re-running this skill against a pre-v1.0.34 config rebuilds docker.recommended_plugins from scratch from the current host plugin list — legacy entries (e.g. marketplace == "claude-plugins-official" literal) are replaced cleanly. The entrypoint warns and skips any legacy entry whose marketplace is not an org/repo until the operator re-runs this skill once.
On container-side claude plugin marketplace add / plugin install failure (either in entrypoint logs or when re-running the command manually after boot): if the error mentions SSH auth, HTTPS credentials, gh not found, or .gitconfig read-only, stop immediately — do not attempt workarounds inside the container. The container has no SSH client, no gh CLI, and .gitconfig is bind-mounted read-only by design. Iterating on GIT_CONFIG_NOSYSTEM, git config --global url...insteadOf, or similar is guaranteed to fail and wastes the operator's time. Surface the error to the operator verbatim and move on — no retry unless the operator changes something host-side (makes the repo public, mirrors it, etc.) and asks to retry.
If the operator selected plugins that have corresponding scheduled_checks entries in hatch Phase 4 (claude-code-setup, claude-md-management, skill-creator, feature-dev), also record those scheduled_checks entries if not already present.
After plugin selection is finalized, union the two sources of apt package candidates before writing to config:
Project candidates (from step 2.3 scan) — project-owned signals (native addons, Python stack, build tools, etc.). Live-scanned from project files each run.
Plugin declarations — for each confirmed entry in docker.recommended_plugins, locate the plugin's installed root via claude plugin list --json (inspect path fields). In the plugin root:
## Docker apt dependencies section in skills/hatch/SKILL.md.DOCKER.md file at the plugin root with the same section.## heading). Ignore lines starting with #.^[a-z0-9][a-z0-9+\-.]+$. Entries that fail are dropped with a clear warning to the operator — never passed to Dockerfile rendering.<package-name> — declared by <plugin-name>.Unified confirmation prompt — present the combined deduped list with origin labels:
Proposed image packages:
libsqlite3-dev — project signal (package.json sqlite3)
<package-a> — declared by <plugin-name>
<package-b> — declared by <plugin-name>
Operator approves, removes, or adds entries. Write the approved set as the final docker.packages (passed to step 7c for config.json write).
Quick mode auto-accept: if every entry in the combined list (a) passed the validator regex and (b) came from a safelisted source — meaning project-signal entries OR entries declared by plugins where is_safelisted(marketplace) is true (claude-plugins-official + gtapps/*) — skip the prompt and accept the list silently. Print a one-line summary so the operator sees what landed: Quick auto-accepted N image packages (all from safelisted sources). Configure later via /hermit-settings docker. If ANY entry came from a third-party plugin (operator-confirmed in Step 7b), fall through to the prompt — defaults stop applying once a third-party source contributed apt deps.
Now that docker.packages (Step 7b.packages), channel state dirs (Step 7), and the auth/network choices (Step 2) are finalized, render the three base files in one call. render-docker-templates.ts derives every {{PLACEHOLDER}}, renders Dockerfile.hermit + docker-compose.hermit.yml, cp-copies the entrypoint verbatim, and fails loud (exit 1, nothing written) if any placeholder survives. Anchor the argv to the absolute <PROJECT_ROOT> (Step 1) — cwd can drift after earlier docker compose / tmux calls. Pipe the Step 4 input object on stdin:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/render-docker-templates.ts <PROJECT_ROOT> <<'HERMIT_RENDER_JSON'
{
"packages": [...],
"auth": "oauth-token" | "api-key",
"channels": { "envLines": [...], "volumeLines": [...] },
"agentHookProfile": "strict",
"tmuxSessionName": "<resolved>",
"networkMode": "bridge" | "host",
"gitIdentityMount": true | false
}
HERMIT_RENDER_JSON
The script prints { "written": [...], "entrypointCopied": true, "manifestSeed": {...} }. If gitIdentityMount was false, surface the summary note from Step 4.
Record the template baselines in the manifest (arms the hermit-evolve drift signals) by piping report.manifestSeed — emitted with absolute paths and the current plugin version — straight into manifest-seed.ts. Do not hand-compute the hashes.
Pipe only the value of the manifestSeed field (the { "pluginVersion", "entries" } object): NOT the whole render stdout envelope. manifest-seed.ts expects a top-level {pluginVersion, entries}; handing it the full {written, entrypointCopied, manifestSeed} object seeds no baselines (the drift signals silently never arm).
echo '<the manifestSeed object only: {"pluginVersion":...,"entries":[...]}>' | bun ${CLAUDE_PLUGIN_ROOT}/scripts/manifest-seed.ts <PROJECT_ROOT>/.claude-code-hermit
The manifestSeed payload deliberately hashes the on-disk rendered entrypoint (copied verbatim, so its hash equals the upstream template's — lets hermit-evolve Step 5c manage it as a boot-critical file) and the two upstream .template files (never the rendered output, which differs from upstream forever due to substitution — lets Step 10 report upstream drift without false positives). manifest-seed.ts preserves every other manifest key (templates//bin/ baselines, sibling-hermit keys) and refuses to overwrite a present-but-corrupt manifest.
Pre-write gate for docker.recommended_plugins: Re-run the step 7b.10 assertion on every entry. If any fails, do not write — return to step 7b.3 to re-resolve. This gate is the final backstop; do not bypass it even "just this once."
Write all collected Docker settings to config.json in a single update:
docker.network_mode (from Step 2)docker.packages (from Step 7b.packages)docker.recommended_plugins (from Step 7b, post-gate)channels.<channel>.state_dir (from Step 7, if applicable)Merge into existing config — never remove existing keys.
Print generated files summary:
Dockerfile.hermit — container image definition
docker-entrypoint.hermit.sh — startup script
docker-compose.hermit.yml — orchestration config
.env — auth credentials
Auth token (apikey only): If operator chose apikey and .env already has a real key (not placeholder), skip. Otherwise ask with AskUserQuestion (header: "API key"): Skip (add ANTHROPIC_API_KEY to .env manually before starting) / Enter now (paste key via Other). If "Enter now" and a key was typed via Other: update .env.
Build and start:
Quick: silently treat as Yes — build now and proceed directly to the build sub-section below. No prompt.
Advanced: ask with AskUserQuestion (header: "Deploy"): Yes — build now (run hermit-docker up immediately) / No — manual (print commands to run later).
If "No — manual": Print the full manual deployment guide below, then skip directly to Step 9 (do not attempt Login, Workspace trust, or Channel pairing — the container is not running yet):
Manual deployment guide
──────────────────────
1. Start the container:
.claude-code-hermit/bin/hermit-docker up
2. (OAuth only) From a second terminal, complete login:
.claude-code-hermit/bin/hermit-docker login
3. Accept first-run prompts (press Ctrl+B D to detach when done):
.claude-code-hermit/bin/hermit-docker attach
Screen 1 — Workspace trust: press Enter to accept.
Screen 2 — Permission mode acknowledgement (appears for bypassPermissions AND auto; skip for acceptEdits/default/dontAsk):
- bypassPermissions: arrow keys → "Yes, I accept" → Enter.
- auto: "Enable auto mode?" → press 1 then Enter (persists in the named volume).
4. (Channels only) Pair each bot — DM it to get a 6-char code, then run
(send text and Enter as two separate calls with a 0.5s pause — one-shot
text+Enter is swallowed as bracketed paste):
docker exec <container> tmux send-keys -t <session> \
'/<plugin>:access pair <code> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
sleep 0.5
docker exec <container> tmux send-keys -t <session> Enter
docker exec <container> tmux send-keys -t <session> \
'/<plugin>:access policy allowlist'
sleep 0.5
docker exec <container> tmux send-keys -t <session> Enter
Verify access.json landed at: .claude.local/channels/<plugin>/access.json
5. Verify everything is healthy:
.claude-code-hermit/bin/hermit-status
Re-run /claude-code-hermit:docker-setup any time you want guided help.
If "Yes — build now":
docker compose up creates it owned by root, making it unwritable by the claude user inside the container. For each configured channel run mkdir -p .claude.local/channels/<plugin>. Then run mkdir -p .claude-code-hermit/state && touch .claude-code-hermit/state/.setup-mode to put the container in setup mode (suppresses the bootstrap prompt so channel pairing commands land on an idle REPL, not a busy session turn). Then run docker compose -f docker-compose.hermit.yml up -d --build — builds and starts. Help fix errors (daemon not running, network, disk). Do not use .claude-code-hermit/bin/hermit-docker up here — its trailing echo prints attach/detach instructions that look like imperative commands and can mislead the LLM running this skill into executing them mid-setup. The final hand-off at step 9 provides the canonical attach guidance.docker compose -f docker-compose.hermit.yml ps --status running --format '{{.Service}}' every 2s for up to 10s. If the service appears — continue to the next sub-section. If it never appears after 10s, run docker compose -f docker-compose.hermit.yml logs --tail=30 hermit and show the output. Suggest a targeted fix based on the log:
docker info errors → start Docker Desktop or sudo systemctl start docker, then re-run this skill.env var → docker compose -f docker-compose.hermit.yml config 2>&1 | grep -i error names itss -tlnp | grep <port> finds what's using the published port
Do not continue to Login / Workspace trust / Channel pairing while the container is down — stop here and ask the operator to fix and re-run the skill.Login (oauth only): If operator chose oauth, proceed only once the container is confirmed running. Guide them through login:
.claude-code-hermit/bin/hermit-docker login
This opens a claude REPL inside the container. Type /login, follow the URL in a browser, then paste the code back when prompted. Type /exit when done — the hermit starts automatically.AskUserQuestion (header: "Login") — "Done — login succeeded" / "Failed — couldn't complete login". Do not poll logs in a loop. Do not rebuild or restart the container. hermit-docker login already verifies .credentials.json and exits non-zero if absent, so a "Done" answer means creds are present."Failed": run docker compose -f docker-compose.hermit.yml logs --tail=30 hermit and show output. Suggest the targeted fix:
hermit-docker login (codes expire in ~10 min)docker compose exec -T hermit ls /home/claude/.claude/.credentials.json (should exist after login); missing = named volume not mounted — check docker-compose.hermit.yml volume entry
Then stop — operator re-runs /claude-code-hermit:docker-setup after resolving the issue.First-run acceptance (workspace trust + bypass mode): Before asking the operator to attach, verify the tmux session exists inside the container (the entrypoint may still be installing plugins):
docker compose -f docker-compose.hermit.yml exec -T hermit tmux has-session -t <TMUX_SESSION_NAME>
If the session is not ready, wait and retry every 5s for up to 30s. If still absent after 30s, show the last 30 lines of entrypoint logs (docker compose logs --tail=30 hermit) to help diagnose, then stop.
Note: a running tmux session does not mean claude has finished booting — it may still be sitting on an acceptance screen waiting for input.
Once the session exists, tell the operator:
"Attach now and accept two prompts in order — claude will look frozen until you do, and later steps will misdiagnose it as a crash if you skip this:"
.claude-code-hermit/bin/hermit-docker attachScreen 1 — Workspace trust (always): You'll see "Accessing workspace … Quick safety check: Is this a project you created or one you trust?" — press Enter to accept.
Screen 2 — Permission mode acknowledgement (only if
permission_modeisbypassPermissionsORauto):
- If
bypassPermissions: you'll see the--dangerously-skip-permissionsacknowledgement. Use the arrow keys to select "Yes, I accept", then press Enter.- If
auto: you'll see "Enable auto mode?" with three options. Press 1 then Enter ("Yes, and make it my default mode") so the acknowledgement persists in the named volume and won't re-prompt on future restarts.After accepting, you'll see a blank claude prompt — that's expected during setup. The skill will send pair commands from here; don't type
/sessionyourself.Then press Ctrl+B, D to detach.
Read config.permission_mode from .claude-code-hermit/config.json. If it is NOT bypassPermissions AND NOT auto, omit the "Screen 2" paragraph entirely. Otherwise emit it, and inside the paragraph keep only the branch matching the resolved mode (drop the other bullet).
Wait for the operator to confirm they have detached before continuing.
Channel pairing (skip if no channels or no tokens configured):
Before pairing, confirm the operator has completed the first-run acceptance step above — if they haven't, tmux send-keys commands will be swallowed by the consent screen and appear to do nothing.
Confirm the tmux session still exists (reuse the has-session check from the acceptance step). If it's gone, surface container logs and stop.
For each channel, first verify the token is configured — check that .claude.local/channels/<plugin>/.env exists and contains the expected *_BOT_TOKEN var. If missing, skip pairing for this channel and tell the operator: "No token configured for <channel> — write it to .claude.local/channels/<plugin>/.env, restart the container, then re-run /claude-code-hermit:docker-setup to pair." Move to the next channel.
If the token is present, ask if already paired. If not:
AskUserQuestion (header: "<channel> pairing") — "I have the code" / "Skip this channel". On "I have the code": ask for the 6-char code via Other (header: "Bot code").Enter as two separate send-keys calls with a sleep 0.5 between them — Claude Code's TUI treats text+Enter in one burst as bracketed paste and turns Enter into a literal newline instead of submit (same fix as scripts/hermit-start.ts:611-617):
docker compose -f docker-compose.hermit.yml exec -T hermit \
tmux send-keys -t <session> '/<plugin>:access pair <code> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
sleep 0.5
docker compose -f docker-compose.hermit.yml exec -T hermit \
tmux send-keys -t <session> Enter
docker compose -f docker-compose.hermit.yml exec -T hermit tmux send-keys -t <session> '/<plugin>:access policy allowlist'
sleep 0.5
docker compose -f docker-compose.hermit.yml exec -T hermit tmux send-keys -t <session> Enter
AskUserQuestion (header: "Pair result") — "Bot confirmed paired" / "No response". On "No response": run docker compose exec -T hermit tmux capture-pane -t <session> -p, show output, and skip access.json verification for this channel — don't fail the whole setup.access.json landed in the right place (only on "Bot confirmed paired"): Check .claude.local/channels/<plugin>/access.json. If absent, run docker compose exec -T hermit tmux capture-pane -t <session> -p and show output. If it landed in ~/.claude/channels/<plugin>/ instead, move it:
docker compose exec -T hermit bash -c 'src="${CLAUDE_CONFIG_DIR:-/home/claude/.claude}/channels/<plugin>/access.json"; dst="<project_path>/.claude.local/channels/<plugin>/"; [ -f "$src" ] && mkdir -p "$dst" && mv "$src" "$dst" && echo moved'
"Already paired" was chosen or pairing was skipped this run):
Read tool on <project_path>/.claude.local/channels/<plugin>/access.json (host file). If ackReaction is already non-empty, skip — preserve operator customization.Edit tool to set the ackReaction value to "👀" while preserving every other key in the file unchanged (read-modify-write a single field — do NOT overwrite the file with a fresh object). The bind-mount makes the change visible inside the container immediately — no tmux round-trip, no LLM turn, no race with the Step 8b shutdown.ackReaction is non-empty)."Already paired" was chosen; skip only if imessage, if "Skip this channel" was chosen, or if pairing returned "No response"):
AskUserQuestion — label and prompt vary by channel:
discord: header "Server channel" — "Want the hermit to also listen in a Discord server channel? Channel ID: enable Developer Mode in Discord settings → right-click the channel → Copy Channel ID."telegram: header "Group chat" — "Want the hermit to also listen in a Telegram group? Group ID: forward a message from the group to @userinfobot or use @RawDataBot. Group IDs are negative integers (e.g. -1001234567890).""Yes — add a channel" (discord) / "Yes — add a group" (telegram) with ID captured via Other; "Skip — DMs only".Other; each subsequent ID from step 3d's Other — loop until "Done"):
a. Ask with AskUserQuestion (header: "Mention required") for this ID:
"Yes — require @mention" (default — safer for noisy channels)"No — respond to all messages"
b. Send group add into tmux using the same two-call send-keys pattern as sub-step 2 (text, sleep 0.5, Enter):"Yes — require @mention":
docker compose -f docker-compose.hermit.yml exec -T hermit \
tmux send-keys -t <session> '/<plugin>:access group add <channelId> — save access.json to <project_path>/.claude.local/channels/<plugin>/ not ~/.claude'
sleep 0.5
docker compose -f docker-compose.hermit.yml exec -T hermit \
tmux send-keys -t <session> Enter
"No — respond to all messages": same but with --no-mention appended before the — hint.
c. Confirm (text only): "Sent group add for <channelId>. Will verify after all channels are added."
d. Ask with AskUserQuestion (header: "Add another?") — "Yes — add another" with the next ID via Other; "Done — continue". On "Done — continue": exit the loop.Read after the loop): open <project_path>/.claude.local/channels/<plugin>/access.json. For each ID added in step 3, confirm groups.<channelId> is present with the expected requireMention value. For any missing: run docker compose exec -T hermit tmux capture-pane -t <session> -p, surface the output, and warn — do not fail the whole setup. Then proceed to sub-step 8.If "skip": tell them to DM the bot later and run the commands manually.
Skip this step entirely if the operator chose "No — manual" at step 8. The container isn't running, so there's nothing to restart.
Tell the operator (set expectations — this step takes ~30-60s and otherwise looks like a hang):
"Finalizing setup with a clean restart so the first real hermit session starts with everything configured and plugins fully loaded."
Then run, in sequence:
.claude-code-hermit/bin/hermit-docker down — sends /session-close --shutdown via tmux and polls for graceful close up to 60s before removing the container. If it prints "Timed out waiting for graceful close; forcing stop", flag it in the final summary (session that witnessed setup didn't close cleanly — not blocking but worth noting).
docker compose -f docker-compose.hermit.yml up -d — recreates the container. Do not use hermit-docker up here for the same LLM-misleading-echo reason as step 8. Docker's named volume preserves credentials, plugins, workspace trust. Bind-mounts preserve .claude.local/channels/<plugin>/access.json and .claude-code-hermit/.
Re-verify the container stayed running (same poll as step 8.2). If it failed to come back up, run docker compose -f docker-compose.hermit.yml logs --tail=30 hermit, show the output, and apply the same targeted hints as step 8.2 (daemon down, build failure, port conflict). Stop — don't proceed to step 9.
Why this step exists: mid-setup, claude REPL starts before plugins are fully enabled and channel pairing completes. The session it was running is a "bootstrap session" full of setup chatter. A clean restart gives the operator a first real session with correctly-loaded plugins, fresh tmux state, and no config-time noise.
Why not hermit-docker restart: Docker's default stop_grace_period is 10s, which is shorter than the entrypoint's 30-iteration session-close poll — SIGKILL can land mid-close. (We raised stop_grace_period to 60s in the compose template, so restart is now also safe in principle, but down+up gives a recreated container which is stronger: clears ephemeral container-layer state and re-runs the entrypoint from a clean slate.)
Enable the Docker watchdog. Docker hermits run the watchdog from the entrypoint loop, so turn it on now:
jq '.watchdog.enabled = true' .claude-code-hermit/config.json > .claude-code-hermit/config.json.tmp \
&& mv .claude-code-hermit/config.json.tmp .claude-code-hermit/config.json
This only flips .watchdog.enabled — the other watchdog tuning keys (stale_factor, escalate_after, operator_grace) are preserved.
Run .claude-code-hermit/bin/hermit-status and show output. no session is the expected output on a fresh setup — it means the container is up and will start its first session on the next cron routine or channel message. Do not add sleep before hermit-status; if you need to wait for a session to appear, use Monitor with an until-loop (not chained sleeps).
If healthy:
You're all set! Your hermit is live and running autonomously.
.claude-code-hermit/bin/hermit-docker up — start container
.claude-code-hermit/bin/hermit-docker down — graceful stop (--force to skip)
.claude-code-hermit/bin/hermit-docker attach — connect to tmux session
.claude-code-hermit/bin/hermit-docker bash — shell into container
.claude-code-hermit/bin/hermit-docker login — OAuth login
.claude-code-hermit/bin/hermit-docker logs -f — follow logs
.claude-code-hermit/bin/hermit-docker restart — restart container
.claude-code-hermit/bin/hermit-docker update — rebuild image + update plugins (durable pin move) + auto-evolve
.claude-code-hermit/bin/hermit-status — quick check
If something looks wrong, help diagnose — suggest concrete next steps.
Why .hermit suffix? The project may already have its own Dockerfile / docker-compose.yml. Hermit-namespaced files avoid conflicts.
Why *_STATE_DIR as OS env vars? MCP servers (channel plugins) are separate processes that inherit OS env — they don't read settings.local.json. Without these env vars, the MCP server defaults to ~/.claude/channels/<plugin>/ which inside the container resolves to /home/claude/.claude/channels/ — not bind-mounted and lost on restart.
Why bind-mounts for channel state? Channel plugins hardcode writes to ~/.claude/channels/<plugin>/. In Docker, that path is on the named config volume — outside the project tree. Even with bypassPermissions, Claude Code's path boundary check triggers a permission prompt for writes outside the project dir. Bind-mounting the project-local state dir into ~/.claude/channels/<plugin>/ makes the kernel present both paths as the same filesystem, avoiding symlink resolution issues and keeping writes inside the project boundary.
Why a named volume for config? The container gets its own Claude Code config (/home/claude/.claude) via a Docker named volume instead of sharing the host's ~/.claude. This prevents container state (onboarding, auto-memory, plugin cache) from leaking into host interactive sessions. The volume persists across restarts — onboarding bypass and channel plugins survive docker compose restart. First run is slower while the volume is populated.
Want shared config instead? Replace the claude-config named volume with a bind-mount in docker-compose.hermit.yml: - ${HOME}/.claude:${HOME}/.claude and set CLAUDE_CONFIG_DIR=${HOME}/.claude. Not recommended — changes in either direction leak.
Domain-plugin apt dependencies. Declare system packages your plugin needs in a ## Docker apt dependencies section in the plugin's hatch SKILL.md or a DOCKER.md at the plugin root. See step 7b.packages and Creating Your Own Hermit — Docker dependencies.
Why the three hardening stanzas (no-new-privileges, cap_drop, pids_limit)? The container may run with bypassPermissions (or the default auto mode), and the recommended-plugins flow accepts third-party marketplaces — defense in depth matters here. The load-bearing one is no-new-privileges:true — it blocks setuid escalation at the kernel level, which is a real vector against future supply-chain compromise of any installed plugin. cap_drop: ALL is incremental: the container already runs as non-root claude so most caps were already unreachable, but dropping them explicitly closes the kernel-enforced ceiling. pids_limit: 2048 is a resource bound, not a security primitive — it caps fork-bomb-style payloads with comfortable headroom over hermit's ~80 PID steady state. Hermit's runtime needs none of what's removed (verified across tmux, claude CLI, bun, jq, npm, git+HTTPS plugin installs). Operators extending the container with services on privileged ports (<1024), setuid helpers, or high-PID workloads must relax the relevant stanza explicitly. See Security — Container Hardening for the full rationale.
Want stronger isolation? v1.0.26 ships an opt-in advanced wizard, /claude-code-hermit:docker-security, that adds LAN containment with DNS policy (firewall + DNS sidecar with port-53 redirect for actual enforcement), read-only root filesystem with smoke test, resource bounds with kernel hygiene sysctls, and a boot-time plugin-install audit log. Each toggle is opt-in with honest cost/benefit framing, runs verification against the live container, and is fully reversible. Note: the LAN containment toggle is hard-skipped when docker.network_mode: "host" is in use — bridge networking required. See Docker Security.
npx claudepluginhub p/gtapps-claude-code-hermit-plugins-claude-code-hermitSets up and launches a Docker devcontainer running Claude Code with --dangerously-skip-permissions for autonomous sandboxed coding without prompts. Triggers on 'yolo' or 'autonomous mode'.
Guides Docker usage: debugging container failures, writing Dockerfiles, docker-compose for integration tests, image optimization, volumes, multi-stage builds, and deployments.
Develops secure Docker containers with multi-stage builds, non-root users, minimal Alpine/slim images, Skaffold workflows, and 12-factor principles. For Dockerfiles, container security, and orchestration.