plugins/claude-code-hermit/skills/docker-setup/SKILL.md
Generates Docker scaffolding and walks the operator through the full deployment — token setup, build, start, MCP plugin configuration, workspace trust, and verification. Offers to back up and overwrite existing Docker files. Run after /hatch.
npx skillsauth add gtapps/claude-code-hermit docker-setupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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: Run all checks and commands sequentially — do not use parallel tool calls.
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.
docker --version. If missing: "Docker isn't installed — grab it from https://docs.docker.com/get-docker/ and come back!".claude-code-hermit/config.json exists. If missing: "Run /claude-code-hermit:hatch first, then come back."/mnt/c/ or /mnt/d/: abort — "Clone inside WSL2 (e.g. /home/you/project) and run from there."Dockerfile.hermit, docker-entrypoint.hermit.sh, docker-compose.hermit.yml at project root. If any found:
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 in step 4 via {{AGENT_HOOK_PROFILE}}.
Execution moved. Template rendering is now performed at Step 7b.6 — AFTER plugin resolution (Step 7b) and apt-package union (Step 7b.packages) have finalized
docker.packages. The placeholder definitions are documented here for reference; the actualRead+Writecalls happen later, with all values resolved.Why:
{{PACKAGES_BLOCK}}substitution depends ondocker.packages, which isn't finalized until Step 7b.packages. Rendering at Step 4 (the original position) consumed an empty/pre-resolution package list. Channel placeholders ({{CHANNEL_ENV_LINES}},{{CHANNEL_VOLUME_LINES}}) similarly depend on Step 7 channel-token configuration.
The three templates live in ${CLAUDE_SKILL_DIR}/../../state-templates/docker/. Placeholder substitution rules per file (do NOT read or write here — all file I/O happens at Step 7b.6):
Dockerfile.hermit (from Dockerfile.hermit.template):
{{PACKAGES_BLOCK}} — if docker.packages is non-empty, replace with:
# Project-specific packages (from config.json docker.packages)
# To modify: /hermit-settings docker, then rebuild
RUN apt-get update && apt-get install -y --no-install-recommends \
<space-separated packages> && \
rm -rf /var/lib/apt/lists/*
If empty, remove the {{PACKAGES_BLOCK}} line entirely. The block is placed after the npm install layer so changing project packages doesn't bust the npm cache.docker-entrypoint.hermit.sh (from docker-entrypoint.hermit.sh.template):
config.json at runtime. Copy verbatim; the resulting file is correct without any edits.docker-compose.hermit.yml (from docker-compose.hermit.yml.template):
{{AUTH_ENV_LINE}} — If apikey: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n. If oauth: empty string (remove the line entirely — OAuth credentials live in .credentials.json inside the named volume, written by claude /login).{{CHANNEL_ENV_LINES}} — for each enabled channel, derive the *_STATE_DIR value from channels.<name>.state_dir in config.json and add an indented environment line:
- DISCORD_STATE_DIR=${PWD}/.claude.local/channels/discord - TELEGRAM_STATE_DIR=${PWD}/.claude.local/channels/telegram
Remove {{CHANNEL_ENV_LINES}} entirely if no channels configured.{{CHANNEL_VOLUME_LINES}} — for each configured channel, add an indented volume bind-mount that maps the project-local state dir into the container's ~/.claude/channels/ path. This ensures channel plugin writes stay inside the project tree (no permission prompts with bypassPermissions):
- ${PWD}/.claude.local/channels/discord:/home/claude/.claude/channels/discord - ${PWD}/.claude.local/channels/telegram:/home/claude/.claude/channels/telegram
Remove {{CHANNEL_VOLUME_LINES}} entirely if no channels configured.{{AGENT_HOOK_PROFILE}} — always strict for Docker (enforces always_on deny patterns inside the container){{TMUX_SESSION_NAME}} — resolved session name{{NETWORK_MODE_LINE}} — If docker.network_mode is "host": replace with # WARNING: host networking exposes all host-local services to the container.\n network_mode: host. If "bridge" (default): remove the line entirely (bridge is Docker's default — no directive needed).~/.gitconfig exists on the host. If it does not exist, remove the .gitconfig bind-mount line from the rendered file and add a note in 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."Resolve the path key: pwd | sed 's|/|-|g' (keeps leading dash — matches Claude Code's format, e.g. -home-user-myproject).
Check if ~/.claude/projects/<path-key>/memory/MEMORY.md exists on the host:
.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: Write the default set from state-templates/deny-patterns.json into the target settings file's permissions.deny.
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.
Do NOT include the always_on set — those patterns (docker, ssh, kubectl, git push --force, etc.) are enforced by the enforce-deny-patterns.ts hook when AGENT_HOOK_PROFILE=strict, which the container runs with. Writing them to settings would block docker commands needed by later steps in this skill.
If the target settings file already has permissions.deny, merge (never remove existing entries). 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: The hermit Docker base image ships bubblewrap and socat (added in v1.1.2), so the Claude Code sandbox can run inside the container. hermit-start.py automatically writes sandbox.enableWeakerNestedSandbox: true to .claude/settings.local.json on every Docker boot — this is required for bwrap to start inside an unprivileged container. The sandbox profile itself (enabled/disabled, filesystem denies) is set at /hatch time and lives in the operator's settings file; docker-setup does not modify it.
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 claude plugin list --json to enumerate plugins. Each entry includes id (form <plugin>@<marketplace_name>), scope, enabled, projectPath, and installPath. Apply the project-or-local + enabled filter:
enabled == true AND (scope == "project" OR scope == "local") AND projectPath equals the current project root.claude plugin list --json lists across the operator's full config — local-scope plugins from sibling repos carry their own projectPath and must be excluded.
Dedupe rule. After filtering, dedupe surviving entries by (plugin, marketplace_name). If both a project and a local scope entry survive for the same (plugin, marketplace_name), keep the local entry only — 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), docker.recommended_plugins (Step 7b), channel state dirs (Step 7), and the auth/network choices (Step 2) are all finalized, perform the template rendering described in Step 4 above. Write the three rendered files to project root:
Dockerfile.hermit — substitute {{PACKAGES_BLOCK}} with the finalized docker.packages.docker-entrypoint.hermit.sh — copy verbatim; no placeholder substitution (session name is resolved from config.json at container startup).docker-compose.hermit.yml — substitute {{AUTH_ENV_LINE}}, {{CHANNEL_ENV_LINES}}, {{CHANNEL_VOLUME_LINES}}, {{AGENT_HOOK_PROFILE}}, {{TMUX_SESSION_NAME}}, {{NETWORK_MODE_LINE}}, plus the git-identity bind-mount handling.Use the placeholder rules from Step 4 verbatim.
Record docker template baselines in the manifest (arms the hermit-evolve drift signals). After writing the three files, merge these keys into .claude-code-hermit/state/template-manifest.json (read existing first; create { "version": 1, "files": {} } if absent; overwrite only these keys, keep all others — same merge rule as hatch):
"docker/docker-entrypoint.hermit.sh": { "sha256": "<hash of the UPSTREAM state-templates/docker/docker-entrypoint.hermit.sh.template>", "plugin_version": "<current plugin version>" } — the entrypoint is copied verbatim, so the upstream-template hash equals the on-disk hash. This baseline lets hermit-evolve Step 5c manage the entrypoint as a boot-critical file (keep operator edits, refresh when upstream moves)."docker/docker-compose.hermit.yml.template" and "docker/Dockerfile.hermit.template": { "sha256": "<hash of the UPSTREAM template>", "plugin_version": "<current version>" } — these render with substitution, so hash the upstream template, not the rendered output. Lets hermit-evolve Step 10 report upstream drift (status: changed) without false positives from placeholder substitution.Compute the current plugin version from ${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json. Use bun ${CLAUDE_PLUGIN_ROOT}/scripts/lib/hash.ts semantics (sha256 of the file bytes) — match how hatch hashes templates//bin/.
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.py: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.
data-ai
Initializes or resumes a work session. Loads context from OPERATOR.md and SHELL.md, orients the agent, and establishes what to work on. Use at the beginning of every work session.
tools
Evolves hermit configuration and templates after a plugin update. Detects version gaps, presents new features, walks through new settings. Run after updating the plugin.
testing
Initializes the autonomous agent in the current project. Creates the state directory, templates, OPERATOR.md, and config.json. Appends session discipline to CLAUDE.md. Detects installed hermits. Run once per project, like git init.
testing
Returns a 5-line executive summary of recent work. Checks active session first, falls back to latest report. Activates on messages like "brief", "what happened", "morning update", "overnight summary".