| name | nirvana-agent-deployment |
| description | Deploy and maintain Nirvana agents as self-contained Docker containers with Signal, Photon, or API platforms. Covers compose files, port conventions, signal-cli daemon setup, config.yaml standards, common pitfalls, and custom image building. |
Nirvana Agent Deployment
Self-contained Docker agents living at J:\NIRVANA-AGENTS\<agent>\. Each agent has its own data/, signal-data/ (if Signal), SOUL.md, and docker-compose.yml.
Agent Inventory
| Agent | Port | Platform | Internal Daemon | Identity |
|---|
| Aubrey | 8201:8642 | Photon iMessage | — | +16282649071 |
| Chase | 8203:8642 | Signal + API | :18084 | +15135466426 |
| Ethoscope | 8202:8642 | Signal | :18083 | +15133273916 |
| Magneto | 8204:8642 | Signal | :18082 | +19317174760 |
Signal daemon ports are per-agent for clarity — each container has its own network namespace so there's no port collision, but unique ports make debugging and health checks unambiguous.
Directory Structure
J:\NIRVANA-AGENTS\<agent>\
docker-compose.yml # Self-contained, no external volume references
SOUL.md # Agent identity/personality (required)
data\ # Hermes home (config.yaml, .env, sessions, skills)
.env # Credentials (never commit)
config.yaml # Model, platform, agent settings
signal-data\ # Only for Signal agents
data\ # Signal-cli expects data/accounts.json + data/<number>/
accounts.json
Compose Standards
.env File — Single Source of Truth
The .env file lives at data/.env ONLY. Never duplicate it at the compose root. The compose env_file: - data/.env directive reads it. Duplicate outer .env files cause stale values to override correct ones and create confusion.
Port Mapping (ALL agents)
ports:
- "127.0.0.1:820x:8642"
Environment (ALL agents)
environment:
- HERMES_HOME=/opt/data
- HERMES_ALLOW_ROOT_GATEWAY=1
- API_SERVER_HOST=0.0.0.0
- API_SERVER_PORT=8642
API agents (Chase)
API_SERVER_KEY=<key>
HERMES_DOCKER_EXEC_AS_ROOT=1 (ALL agents — bypass s6 privilege drop)
The /opt/hermes/bin/hermes shim calls s6-setuidgid hermes when invoked as root. If the hermes user is renamed or the s6 user lookup fails, the gateway crashes with s6-envuidgid: fatal: unknown user: hermes.
Fix: set HERMES_DOCKER_EXEC_AS_ROOT=1 in compose environment. The shim skips the privilege drop entirely — the gateway runs as root. No /etc/passwd dependency. All agents should have this.
environment:
- HERMES_DOCKER_EXEC_AS_ROOT=1
This also means the hermes username in /etc/passwd is irrelevant — the agent's identity comes from SOUL.md, not from the Linux user.
AGENT_NAME — command aliasing (ALL agents)
Set AGENT_NAME=<name> in compose environment. The entrypoint creates /usr/local/bin/<name> → /opt/hermes/.venv/bin/hermes symlink at startup. ethoscope doctor --fix works identically to hermes doctor --fix.
environment:
- AGENT_NAME=ethoscope
Entrypoint snippet:
if [ -n "${AGENT_NAME:-}" ] && [ ! -x "/usr/local/bin/${AGENT_NAME}" ]; then
ln -sf /opt/hermes/.venv/bin/hermes "/usr/local/bin/${AGENT_NAME}"
echo "[agent] Command alias: ${AGENT_NAME}"
fi
GATEWAY_ALLOW_ALL_USERS (ALL messaging agents)
Every agent with a messaging platform (Signal, Photon) needs this in compose environment: or the gateway warns "No user allowlists configured" and may reject incoming messages:
environment:
- GATEWAY_ALLOW_ALL_USERS=true
Signal agents (Magneto, Ethoscope, Chase)
CRITICAL: The daemon runs as root (HOME=/root), NOT as the hermes user (HOME=/opt/data). The entrypoint's export HOME=/opt/data affects the gateway but the nohup background process inherits HOME from the shell at the time of invocation. Always set HOME=/root explicitly before the daemon start, and export HOME=/opt/data after it.
environment:
- SIGNAL_HTTP_URL=http://127.0.0.1:1808X
- SIGNAL_CLI_CONFIG_DIR=/root/.local/share/signal-cli
volumes:
- ./data:/opt/data
- ./signal-data:/root/.local/share/signal-cli
HOME=/root nohup <path>/signal-cli daemon --http 127.0.0.1:1808X > /tmp/signal-daemon.log 2>&1 &
export HOME=/opt/data
nohup <path>/signal-cli daemon --http 127.0.0.1:1808X > /tmp/signal-daemon.log 2>&1 &
export HOME=/opt/data
**Why explicit HOME scoping matters:** If `export HOME=/opt/data` runs BEFORE the daemon start, the daemon inherits HOME=/opt/data and creates/looks for signal data at `/opt/data/.local/share/signal-cli` — the WRONG path. The actual registration data lives at `/root/.local/share/signal-cli` (mounted from `./signal-data`). This mismatch causes the daemon to appear healthy but use the wrong account database, resulting in Signal health check failures and "All connection attempts failed" errors.
```yaml
platforms:
photon:
enabled: true
api_server:
enabled: false
Signal Registration (see references/signal-registration.md)
Golden rule: Never kill the daemon. Never stop the container for registration. The daemon IS the Signal connection. Use RPC via the running daemon's HTTP API, or docker exec with the signal-cli binary that's already installed in the running container.
IMPORTANT: docker exec only works into RUNNING containers. Stopping a container to run a command inside it makes no sense. If you stop it, you can't exec into it. docker restart for config changes, NOT docker stop + docker exec.
The daemon must stay alive. Killing the signal daemon process inside a running container (via docker exec <c> pkill -f "signal.*daemon") cuts the agent's Signal connection. Don't do this. The daemon handles registration, messaging, and identity. Use its RPC HTTP API at http://127.0.0.1:1808X/api/v1/rpc for ALL operations — profile updates, account queries, registration, contacts.
Signal-cli expects data/ subdirectory:
signal-data/
data/
accounts.json # {"accounts": [...], "version": 2}
<number>/ # Account config file (no extension)
<number>.d/ # Account database (account.db, etc.)
Mount path: ./signal-data:/root/.local/share/signal-cli with SIGNAL_CLI_CONFIG_DIR=/root/.local/share/signal-cli
Agent Identity & Command Aliasing
Each agent should have its own name and command (ethoscope doctor --fix, adam status). The base image binary is hermes — we alias it per-agent via compose.
Per-Agent Command Alias
Add AGENT_NAME to compose environment and create a symlink in the entrypoint:
environment:
- AGENT_NAME=ethoscope
- HERMES_DOCKER_EXEC_AS_ROOT=1
if [ -n "${AGENT_NAME:-}" ] && [ ! -x "/usr/local/bin/${AGENT_NAME}" ]; then
ln -sf /opt/hermes/.venv/bin/hermes "/usr/local/bin/${AGENT_NAME}"
echo "[${AGENT_NAME}] Command alias: ${AGENT_NAME}"
fi
This creates /usr/local/bin/ethoscope → the hermes binary. All subcommands work: ethoscope --version, ethoscope doctor, ethoscope config path, etc.
Note on output branding: The internal binary still identifies as "Nirvana" (or "Hermes" if the image is stale). The version string, doctor banner, and gateway startup banner are compiled into the image. Full output rebranding requires rebuilding the image from rebranded source (see Nirvana-Intelligence/.nirvana/harness/rebrand.py).
Agent Identity (SOUL.md + Skin)
Each agent's personality lives in data/SOUL.md. The gateway reads it via display.personality: /opt/data/SOUL.md in config.yaml.
For CLI/TUI branding, place a custom skin at data/skins/<agent>.yaml:
name: ethoscope
branding:
agent_name: "EthoScope"
welcome: "Welcome to EthoScope..."
response_label: " ⚕ EthoScope "
Custom skins are loaded from HERMES_HOME/skins/ (which is /opt/data/skins/ inside the container). Set display.skin: ethoscope in config.yaml to activate.
Agent Factory Pattern (hatching new agents)
To create a new agent from scratch:
- Create
J:\NIRVANA-AGENTS\<name>\ directory
- Copy template compose from
templates/docker-compose-signal.yml (see references)
- Set
AGENT_NAME=<name> and appropriate port/env vars
- Create
data/ with config.yaml + .env + SOUL.md
docker compose up -d
Minimal API-only agent (no Signal) needs:
data/config.yaml with model, agent, platforms.api_server settings
data/.env with API keys (DEEPSEEK_API_KEY, etc.)
data/SOUL.md with personality
- Compose with
HERMES_DOCKER_EXEC_AS_ROOT=1, GATEWAY_ALLOW_ALL_USERS=true, AGENT_NAME=<name>
Rebranding Surfaces (what needs changing in the image)
The rebrand.py at Nirvana-Intelligence/.nirvana/harness/rebrand.py covers:
agent/prompt_builder.py — system prompt identity
hermes_constants.py — default paths (.hermes → .nirvana)
hermes_cli/banner.py — CLI banner
hermes_cli/main.py — version strings
run_agent.py — HTTP User-Agent
pyproject.toml — package metadata
hermes_cli/tips.py — tip text
gateway/run.py — gateway startup banner
hermes_cli/skin_engine.py — all built-in skin branding defaults
docker/hermes-exec-shim.sh — shim error messages
Run python3 .nirvana/harness/rebrand.py after every git subtree pull. The image must be rebuilt for rebranding to take effect in containers.
Before ANY change: Read the file. List the directory. Check what exists. The user has production setups across multiple domains — blindly batch-deleting MX records, overwriting configs, or touching the wrong agent's data will break things.
One agent at a time: When working on multiple agents, finish the current agent completely before moving to the next. Jumping between Magneto → Ethoscope → Aubrey mid-task leaves work half-done and confuses the user.
Don't assume: Portal-1 being unreachable from your SSH doesn't mean it's "down for reboot." Ask before diagnosing. Don't fabricate statuses.
Common Pitfalls
Signal account "Specified account does not exist"
Three possible causes, in order:
- accounts.json references wrong path — The
"path" entries must match actual directory names in signal-data/data/. If accounts.json says "path":"746138" but the actual files are 190549 and 190549.d, the daemon can't load the account. Fix: update accounts.json to match existing data. Find the correct path by checking which file has "registered":true:
cat signal-data/data/*AccountNumber* | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])"
- Daemon HOME mismatch — WRONG data directory — The entrypoint's
export HOME=/opt/data before nohup signal-cli daemon causes the daemon to look at /opt/data/.local/share/signal-cli/ instead of /root/.local/share/signal-cli/. Fix: HOME=/root before the daemon command — scope it to the daemon only, then export HOME=/opt/data after for the gateway. Symptom: Signal SSE health checks fail with "All connection attempts failed" while the gateway is running. The daemon IS running but reading the wrong (empty or different) data directory.
- Mount path mismatch — Volume mount must match SIGNAL_CLI_CONFIG_DIR. Use
./signal-data:/root/.local/share/signal-cli — NOT /opt/data/.local/share/signal-cli.
- Signal data permissions — daemon writes files as root, but the hermes gateway process runs as UID 10000 and can't read root-owned files. Fix: add
chmod -R a+rw /root/.local/share/signal-cli 2>/dev/null || true in the entrypoint AFTER daemon readiness, BEFORE gateway start.
Photon credentials go in auth.json, NOT config.yaml
Photon project credentials (PROJECT_ID/PROJECT_SECRET) are stored in data/auth.json under credential_pool.photon_project, identical to how Magneto stores them. The config.yaml only needs photon: enabled: true — do NOT add project_id or project_secret to config.yaml.
"photon_project": [
{
"project_secret": "<secret>",
"spectrum_project_id": "<project-uuid>",
"issued_at": 1781430306
}
]
Also add PROJECT_ID=<uuid> and PROJECT_SECRET=<secret> to data/.env for future container recreations. The auth.json is the live config the gateway reads; the .env is the seed for future runs.
Photon plugin missing from custom nirvana-runtime image
The nirvana-runtime image built from our fork does NOT include the Photon plugin directory. The upstream nousresearch/hermes-agent image has it, but our custom build lacks it. Workaround: mount the Photon sidecar from the host as a read-only bind mount:
volumes:
- ./sidecar-index.mjs:/opt/hermes/plugins/platforms/photon/sidecar/index.mjs:ro
And in the entrypoint, create the directory before npm install:
mkdir -p /opt/hermes/plugins/platforms/photon/sidecar
cd /opt/hermes/plugins/platforms/photon/sidecar && npm install --silent 2>&1 || true
Duplicate .env files causing stale config
When both J:/NIRVANA-AGENTS/<agent>/.env and data/.env exist, the outer file may have stale values that override the inner (active) file. The compose env_file: - data/.env reads from data/. The outer file is dead weight. Always delete the outer .env and keep a single data/.env per agent. Check with diff before removing to catch any unique values that need merging.
Signal register --captcha values starting with -
Captcha tokens from signalcaptchas.org often start with - (dash). This breaks argument parsing. Wrap in quotes: --captcha="-ZrrFnw0T...".
Signal registration: captcha lifecycle and rate limits
Signal captchas from https://signalcaptchas.org/registration/generate.html expire within seconds. The user must solve and send the signalcaptcha:// link immediately. The agent must fire the register command the moment the link arrives.
Captcha failure codes:
403 Authorization failed — captcha expired. iPhone browsers intercept the signalcaptcha:// scheme and mangle the URL. Use desktop browser only. Get a fresh captcha.
429 Rate Limited — too many registration attempts. Signal locks the number for a cooldown period (minutes to hours). After any failed attempt, wait at least 60 seconds before retrying. Do NOT immediately retry.
Invalid captcha given — captcha was correctly formatted but rejected by Signal's servers. This is often NOT an actual invalid captcha — it's more likely token corruption in transit. See the file bypass technique below.
⚠️ CAPTCHA TOKEN MASKING — THE #1 REGISTRATION PITFALL:
Hermes' secret redaction (security.redact_secrets) may mangle captcha tokens passed through tool calls (curl -d). The token arrives at the daemon altered, producing "Invalid captcha given" even with a fresh, correct captcha. Fix: write captcha to file on disk, copy into container, read from file in curl:
echo -n "<TOKEN>" > "C:/Users/iAMBLACK/AppData/Local/Temp/captcha.txt"
MSYS_NO_PATHCONV=1 docker cp "C:/Users/iAMBLACK/AppData/Local/Temp/captcha.txt" nirvana-<agent>:/tmp/captcha.txt
MSYS_NO_PATHCONV=1 docker exec nirvana-<agent> sh -c '
CAPTCHA=$(cat /tmp/captcha.txt)
curl -s -X POST http://127.0.0.1:1808X/api/v1/rpc \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"register\",\"params\":{\"account\":\"+NUMBER\",\"captcha\":\"$CAPTCHA\"},\"id\":\"1\"}"
'
IMPORTANT: git-bash /tmp/ maps to a different location than C:/Users/iAMBLACK/AppData/Local/Temp/. Always use the Windows absolute path for docker cp source files.
Verification code:
After successful registration, Signal sends an SMS code. Verify via RPC:
curl -s -X POST http://127.0.0.1:1808X/api/v1/rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"verify","params":{"account":"+NUMBER","verificationCode":"000000"},"id":"1"}'
Note: field is verificationCode, NOT code. Wrong field name returns Unrecognized field "code" error.
Account state check:
docker exec nirvana-<agent> cat /root/.local/share/signal-cli/data/<path>/<accountfile> | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('registered'))"
Voice verification requires SMS attempt first + 60s wait. Sequence: register --captcha TOKEN → wait 60s → register --captcha TOKEN --voice. Never retry rapidly.
Command format:
MSYS_NO_PATHCONV=1 docker exec nirvana-<agent> \
/opt/signal-cli-0.14.5/bin/signal-cli -a <number> \
register --captcha '<signalcaptcha://...>'
Always prefix with MSYS_NO_PATHCONV=1 to prevent Git Bash path mangling.
Workflow: one agent at a time
When managing multiple agents (Magneto, Ethoscope, Chase), finish the current agent's task before moving to the next. Jumping between agents mid-task confuses the user and leaves work half-done. Sequential focus: agent A complete → agent B complete → agent C.
Workflow: wait for explicit instruction before touching containers
The user manages a multi-agent deployment with critical production state. Do NOT restart, down, modify, or touch a container without the user's explicit instruction. Passive inspection (reading configs, checking logs, verifying ports) is fine. Anything that changes running state — restarts, compose down/up, config edits, service calls — requires a direct ask from the user first. When in doubt, ask.
Template Config (Nirvana Base)
All agents start from the base template at J:\NIRVANA-AGENTS\template-config.yaml. This enables:
- Autonomous operation (no approval prompts)
- Holographic memory
- Secret redaction DISABLED (agents see all credentials)
- Web + browser + vision access
- Firecrawl (shared on :3002)
- Jupyter notebooks
- Himalaya email backend
- Subagent auto-approve
Custom nirvana-runtime image is MISSING Photon plugin
The nirvana-runtime image built from our rebranded fork does NOT include the Photon plugin. Agents that need Photon (Aubrey) MUST use the upstream nousresearch/hermes-agent:latest image until Photon is added to our image build.
If an agent already has Photon credentials in auth.json and .env, running hermes photon setup again can overwrite working config. Check grep -i photon data/auth.json data/.env first. If credentials already exist, the issue is likely the sidecar directory being lost after a container rebuild, not missing setup.
Photon VALIDATION_ERROR 422
The spectrum-ts sidecar returning status: 422, code: 'VALIDATION_ERROR' means the Spectrum API rejected the project credentials. Causes:
- Project secret expired or rotated
- Project ID doesn't match the access token's account
- Project was deleted from the Photon dashboard
The sidecar code at /opt/hermes/plugins/platforms/photon/sidecar/ is lost on container rebuild. Mount it as a bind mount or re-run setup.
API server port mismatch in config.yaml
The gateway API always listens on 127.0.0.1:8642 regardless of what api_server.port says in config.yaml. If config says port: 8000, docker port mapping still needs to target 8642. Fix: set api_server.port: 8642 in config to match reality.
platforms: must be at top level, NOT under gateway:
Hermes config has TWO platforms: sections with different meanings. The GATEWAY-LEVEL platforms: (at config root, with signal:, photon:, api_server:) controls which platform adapters the gateway loads. The AGENT-LEVEL platforms: (under gateway:) controls UI display settings only. Putting Signal/Photon under gateway.platforms.signal: will NOT enable the adapter — the gateway will log "SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured" or silently skip it. Always place operational platform config at the top level.
platforms:
signal:
enabled: true
api_server:
enabled: true
port: 8642
gateway:
platforms:
telegram:
streaming: true
gateway:
platforms:
signal:
enabled: true
YAML backslash escaping
Docker compose YAML block scalars (|) treat backslashes LITERALLY. \\ in YAML = \ in shell. Never use \\\\ — that becomes \\ in shell and breaks line continuation. Use single-line commands instead.
api_server must be disabled for Photon-only agents
Photon-only agents (Aubrey) need API_SERVER_KEY but have no API server. Disable it: api_server: enabled: false in config.yaml.
Signal daemon port uniqueness
Each container has its own network namespace — agents CAN share internal port numbers (all using :18082). The port uniqueness constraint only applies to HOST port mappings (8201, 8202, 8203, 8204).
Signal home_channel in compose environment
SIGNAL_HOME_CHANNEL env var accepts a bare UUID or group ID — do NOT prefix with group:.
The group:UUID format causes the gateway to interpret it incorrectly. Use just the raw value:
environment:
- SIGNAL_HOME_CHANNEL=TaqVxN/efehbCKSZyBnQ/FezfnYhH3S4zSZasR6oQYE=
Signal group access control
Groups are controlled via BOTH config.yaml and .env:
config.yaml → platforms.signal.free_response_groups: '*' (all groups) or allow_group_ids: uuid1,uuid2 (specific)
.env → SIGNAL_GROUP_ALLOWED_USERS=*,groupID — comma-separated, wildcard * supported
.env → SIGNAL_ALLOWED_USERS accepts both phone numbers AND UUIDs comma-delimited
Do NOT remove SIGNAL_GROUP_ALLOWED_USERS from .env — it is valid in this deployment.
allowed_users format: comma-separated string (phone numbers and UUIDs)
The user prefers a compact comma-separated string format. Both phone numbers and UUIDs are valid:
allowed_users: '+14708092757,7367adea-fb7d-4d40-b190-c67e1175d6c5,+15133973916'
allowed_users: '+14708092757'
Group config: use allow_group_ids with comma-separated values
For specific Signal groups, use allow_group_ids with comma-separated UUIDs (plain string, no brackets, no YAML list):
platforms:
signal:
enabled: true
allow_group_ids: uuid1=,uuid2,uuid3
allow_group_ids:
- uuid1=
- uuid2
allow_group_ids: '[uuid1=,uuid2]'
Note: allow_group_ids is the field the gateway actually reads. free_response_groups is a different (allow-all-wildcard) field. If you want specific groups, use allow_group_ids.
Signal .env variables reference
Valid Signal env vars (set in data/.env):
SIGNAL_ACCOUNT=+15133273916 # The Signal phone number
SIGNAL_HTTP_URL=http://127.0.0.1:18083 # Daemon RPC endpoint
SIGNAL_ALLOWED_USERS=+14708092757,+15133973916 # Comma-delimited phone numbers (NO UUIDs, NO session IDs — phone numbers only)
SIGNAL_GROUP_ALLOWED_USERS=*,groupid1,groupid2 # Comma-delimited: * = all groups, then specific group UUIDs
SIGNAL_HOME_CHANNEL=groupuuid # Group UUID — NO "group:" prefix, just the raw UUID
TZ=America/New_York # Timezone
🛑 Do NOT use group: prefix in SIGNAL_HOME_CHANNEL. The value should be the raw UUID/ID, not group:TaqVxN/...=. The group: prefix is not valid — it causes resolution failures.
SIGNAL_GROUP_ALLOWED_USERS is a valid Hermes env var. Use * to allow all groups, then append specific group UUIDs. The * + specific IDs together means "all groups AND also ensure these specific ones are in the allowlist." This is separate from free_response_groups: '*' in config.yaml (which also allows all groups at the YAML level).
SIGNAL_ALLOWED_USERS should contain phone numbers only. Do NOT put session UUIDs or group IDs here — they belong in SIGNAL_GROUP_ALLOWED_USERS or config.yaml's platforms.signal.allow_group_ids.
Restart loop: docker exec fails on restarting containers
When a container is crash-looping (restart policy), docker exec returns:
Error response from daemon: Container <id> is restarting, wait until the container is running
You cannot exec into a container that's mid-restart. Workflow:
docker stop <container> — halt the loop
- Verify the bind-mounted config file on the host is correct
- Fix the file if needed
docker start <container> — fresh start with corrected config
- Wait 10-15s and check
docker logs --tail 20 for crash-free startup
s6-envuidgid: fatal: unknown user: hermes — user renamed/corrupted
If an agent renames the hermes user in /etc/passwd (e.g., to ethoscope), the gateway shim at /opt/hermes/bin/hermes tries s6-setuidgid hermes and fails, producing an instant crash loop:
s6-envuidgid: fatal: unknown user: hermes
Fix: HERMES_DOCKER_EXEC_AS_ROOT=1 — Add this to compose environment:. It bypasses the entire privilege-drop mechanism. The gateway runs as root with no username dependency.
environment:
- HERMES_DOCKER_EXEC_AS_ROOT=1
This is safe in containers because:
- All data is in bind-mounted volumes (no host filesystem exposure)
- The container's network is isolated
- The gateway process is the only long-running process
Prefer this approach to fixing /etc/passwd because:
/etc/passwd lives in the container's writable layer — not a bind mount
- The writable layer is lost on container recreation anyway
- No need for
docker commit (which times out on large images — see below)
Docker commit/export timeout on large containers
The nirvana-runtime image with Java 25 + signal-cli produces containers of 9-10GB. docker commit on such containers can time out (120s+). Do not try to commit large containers. Instead:
- Fix config files on the host (compose, config.yaml, scripts)
- Remove the old container:
docker rm nirvana-<agent>
- Recreate from compose:
cd J:/NIRVANA-AGENTS/<agent> && docker compose up -d
- The image provides a fresh
/etc/passwd with the correct hermes user
Docker CLI timeout on Windows git-bash
On this Windows host, several docker commands (ps, logs, exec) intermittently time out in git-bash's terminal() tool while the same commands work reliably from execute_code (Python subprocess). When docker commands time out multiple times, switch to execute_code for docker operations:
import subprocess
r = subprocess.run(['docker', 'ps', '--filter', 'name=nirvana-ethoscope', '--format', '{{.Status}}'],
capture_output=True, text=True, timeout=20)
print(r.stdout)
Also: docker run and docker compose up -d are detected as long-lived servers by the terminal tool. Use background=true + notify_on_complete=true for these. Prefer docker compose up -d over manual docker run — it's simpler and the compose file is the canonical config.
"Another gateway instance" restart loop
After docker compose down && docker compose up -d, the gateway may enter a crash loop:
ERROR gateway.run: Another gateway instance (PID 1) started during our startup. Exiting to avoid double-running.
The container restarts (due to restart: unless-stopped), hits the same error, and loops indefinitely.
Cause: Stale gateway.lock and gateway.pid files in the bind-mounted data/ directory from the previous run. Docker Compose down doesn't clean these — they persist in the volume.
Fix:
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway.lock
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway.pid
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway_state.json
docker restart nirvana-<agent>
Prevention: Always clean stale state files after docker compose down and before up -d.
docker restart does NOT reload env_file or environment
docker restart <container> re-uses the SAME container with the SAME environment. Changes to env_file (like adding PHOTON_PROJECT_ID to data/.env) or compose environment: blocks are NOT picked up by a restart. The container must be fully recreated.
CRITICAL: Never recreate without explicit user permission. Recreating a container (docker compose down && up -d, --force-recreate) invalidates Signal registration — the account UUID becomes null and the agent can no longer send or receive Signal messages. Even with bind-mounted signal-data/, the Signal server detects the environment change and requires re-registration.
When the user says "restart" or "don't recreate": that's a hard constraint. Restart is safe. If a change truly requires recreate (new env vars), inform the user BEFORE acting — list what will happen, including "this will break Signal registration for requiring a captcha re-registration."
Workflow for env-only changes without recreate:
- Add env vars to
data/.env for future reference
- If the value is also supported in config.yaml or auth.json, add it there (the live config)
docker restart the container — picks up config/auth changes
- The
.env vars will be live next time the container is legitimately recreated
Before recreating: clean stale lock files to avoid the restart loop:
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway.lock
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway.pid
rm -f J:/NIRVANA-AGENTS/<agent>/data/gateway_state.json
Custom Image: nirvana-runtime
Source: Nirvana-Intelligence/agents/runtimes/nirvana/ (git subtree of hermes-agent, rebranded to nirvana-runtime)
Build:
cd Nirvana-Intelligence/agents/runtimes/nirvana
uv lock
docker build -t nirvana-runtime:latest .
Full upstream sync workflow: See references/upstream-sync-rebrand.md — covers git subtree pull, merge conflict resolution, lockfile regeneration, and the build/push/verify cycle.
Rebranding + site-packages fix: See references/nirvana-runtime-rebranding.md — covers the site-packages staleness trap, Docker cache busting, and verification of all rebranded surfaces.
Required extras: The Dockerfile must include --extra vision for Pillow/image support AND --extra messaging for Photon/Signal/Telegram plugins. Without --extra vision, agents cannot process images (DeepSeek returns PIL dependency error). Without --extra messaging, Photon and messaging adapters are absent from the image.
Current correct sync line in Dockerfile:
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra vision --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
Push to GHCR:
docker login ghcr.io -u chainchopper -p <GITHUB_TOKEN>
docker push ghcr.io/chainchopper/nirvana-runtime:latest
The lockfile goes stale when pyproject.toml name changes — always uv lock before building.
Docker cache-busting for source-only rebuilds
After rebranding source files (rebrand.py) or changing any file under agents/runtimes/nirvana/, Docker BuildKit may cache the COPY --chown=hermes:hermes . . layer and keep serving the old source. To force the source layer to rebuild without invalidating the expensive uv-sync and npm-install layers:
In the Dockerfile, add ARG CACHEBUST=0 immediately before the COPY:
# ---------- Source code ----------
ARG CACHEBUST=0
COPY --chown=hermes:hermes . .
Then rebuild with a unique CACHEBUST value:
docker build --build-arg CACHEBUST="$(date +%s)" -t ghcr.io/chainchopper/nirvana-runtime:latest -t nirvana-runtime:latest .
This re-executes the COPY and every subsequent step (web/tui build, permissions, export) while keeping the expensive apt-get, npm install, and uv sync layers from cache. Total rebuild time drops from ~10min to ~2min when only source files changed.
Python bytecache poisoning after source rebranding
After rebranding source files and rebuilding the image, runtime output may STILL show old strings ("Hermes Agent" instead of "Nirvana") even though the source files are confirmed correct in the image. Root cause: uv pip install -e "." creates an editable install, but the venv's site-packages/ retains a stale non-editable copy of hermes_cli/ from a previous build. Python imports find the stale site-packages copy first — even if the __editable__ finder .pth file is present.
Diagnosis: Check which file Python actually imports:
docker run --rm --entrypoint /bin/sh nirvana-runtime:latest -c \
'python3 -c "import hermes_cli.main; print(hermes_cli.main.__file__)"'
If it prints /opt/hermes/.venv/lib/python3.13/site-packages/hermes_cli/main.py instead of /opt/hermes/hermes_cli/main.py, you have the stale site-packages problem.
Fix in Dockerfile: Add a cleanup step immediately after uv pip install -e ".":
RUN uv pip install --no-cache-dir --no-deps -e "."
RUN rm -rf /opt/hermes/.venv/lib/python3.13/site-packages/hermes_cli
This ensures only the editable install path remains, forcing Python to use the source tree. Rebuild with cache-bust (see above).
Verification after fix:
python3 -m hermes_cli.main --version
hermes --version
Python pycache poisoning (separate from site-packages issue)
A less common variant: stale .pyc bytecode files in __pycache__/ directories can outlive source changes. If the source shows rebranded strings but runtime doesn't, purge all __pycache__ dirs:
COPY --chown=hermes:hermes . .
RUN find /opt/hermes -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
This is a secondary defense — the site-packages cleanup above is the primary fix.
Shared Infrastructure
Firecrawl
Agents share a single firecrawl instance (firecrawl_redis on port 3002). Firecrawl is stateless per-request — no cross-contamination risk. Each agent's scrape results return only to the requesting agent. Each agent having its own instance would waste ~500MB RAM per instance with no benefit.
curl http://127.0.0.1:820x/health
docker exec nirvana-<agent> cat /opt/data/gateway_state.json
docker logs nirvana-<agent> | grep "signal-cli daemon"
Absorbed Skills
Three session-level siblings were absorbed during the 2026-06-16 curation pass:
- nirvana-agent-ops — Spawning from template, migrating from portal-1, Signal daemon embedded pattern, holographic memory, diagnostics, gateway lock file recovery, template standards
→ Key references moved:
agent-matrix.md, signal-registration-ops.md
- nirvana-agent-orchestration — Core principles, UID ownership, named volume → bind mount migration, dedicated Signal daemon (portal-1 pattern), signal-cli upgrades, DOX soul, private repo doc access
→ Key references moved:
memory-providers.md, signal-cli-upgrade.md
- nirvana-container-agents — Docker compose pattern, Signal daemon embedding, migration workflow, Windows quirks (MSYS2, Docker Desktop networking), Photon plugin persistence, verification checklist
→ Key references moved:
ethoscope-signal-loss.md, magneto-migration.md, photon-plugin-persistence.md, signal-env-vars.md
→ Template: templates/docker-compose-signal.yml
References
-
references/dockerfile-rebranding.md — Dockerfile changes needed after every upstream sync (CACHEBUST, pycache purge, site-packages sync)
-
references/upstream-sync-rebrand.md — Complete upstream sync + rebrand + rebuild workflow (git subtree pull, merge conflicts, site-packages trap, verification)
-
references/nirvana-runtime-rebranding.md — Complete rebranding workflow: rebrand.py surfaces, site-packages trap, Docker cache busting, build/push/verify cycle, per-agent identity layering
-
references/photon-sidecar-validation-error.md — Photon sidecar 422 VALIDATION_ERROR: diagnosis, causes, fixes, prevention
-
references/signal-cli-rpc.md — signal-cli RPC API reference
-
references/signal-registration.md — Signal registration flow: captcha, verification, troubleshooting
-
references/signal-registration-ops.md — Additional Signal registration details from nirvana-agent-ops
-
references/hermes-shim-privilege-drop.md — How the /opt/hermes/bin/hermes shim drops privileges and why env vars from entrypoint may not reach the gateway
-
references/n8n-langflow-integration.md — n8n + LangFlow workflow automation: per-agent deployment, port map, MCP wiring, isolation
-
references/agent-matrix.md — Full agent inventory matrix with status
-
references/memory-providers.md — Comparison of memory providers (built-in vs Holographic)
-
references/signal-cli-upgrade.md — signal-cli version upgrade walkthrough with edge cases
-
references/ethoscope-signal-loss.md — Cautionary tale: container rebuild destroying Signal registration
-
references/magneto-migration.md — Full Magneto migration transcript (portal-1 → local Windows)
-
references/photon-plugin-persistence.md — Photon plugin survival across container rebuilds
-
references/signal-env-vars.md — Signal access control env var formats
-
templates/docker-compose-signal.yml — Canonical signal-enabled docker-compose template
-
templates/docker-compose-api-only.yml — Minimal API-only agent compose template (no Signal)
Session Learnings (2026-06-14/15)
"No messaging platforms enabled" is a timing artifact
The gateway logs this WARNING at startup BEFORE Photon/Signal adapters finish initializing. It does NOT mean platforms failed. Check gateway_state.json for actual state — photon: connected means it IS working.
Never kill daemon or stop container for Signal operations
Use the daemon's RPC HTTP API (http://127.0.0.1:1808X/api/v1/rpc) for ALL Signal operations — profile updates, registration, account queries. Killing the daemon process or stopping the container cuts the Signal connection and prevents registration. docker exec into RUNNING containers only.
captcha registration: tokens expire fast
Signal captchas from signalcaptchas.org may be unreliable. The daemon's RPC register method is the safest approach. Always use the daemon's API, not the CLI binary, to avoid config file lock conflicts with the running daemon. If registration keeps failing, the number may need to be registered on a phone first, then linked as secondary device via signal-cli link.