Infrastructure repository for Retinue (formerly health-agents) — a team
of personal agents, coordinated by Ara. The core is a small generic harness
(the frame); domain capabilities arrive as chambers mounted into it. A
chamber is one mounted repository: a self-contained collection of data and
agents/skills, each of which can be a Claude Code plugin (scoped to its
.retinue/ subdirectory).
Retinue is content-neutral: which chambers exist is declared by the
deployment, not baked into the framework. Two runnable example chambers ship
under examples/chambers/ (Westworld/Dolores and
Hitchhiker/Marvin) as the canonical "how to author a chamber" reference.
Defines these core compose services:
retinue— the main container: the Claude Code runtime, the core agent definitions (Archivist, Herald, Academic, Publisher, Secretary), the dashboard PWA (conversations, projects, and the news feed), and the chamber-mounting harness. (Domain content lives in chambers, not in this service.)signal-gateway— dedicated Signal bridge (Signal CLI + Piper) for one Signal account. Depending on the account's configured mode (see Messaging accounts) it either runs incoming messages as prompts toretinueand replies in audio (control mode), or hands them to the user's triage as incoming mail (inbox mode). Incoming voice notes are transcribed via thesttservice, and incoming images are forwarded to the agent alongside the message text (all three messenger gateways do this — the image travels with thePOST /messageforward and is materialized to disk in theretinuecontainer so the session can open it). It also exposes an outbound/sendHTTP endpoint soretinuecan initiate Signal messages (alerts, escalations, daily briefings) viascripts/signal-push.py, each with a spoken rendering and optional images.stt— a small speech-to-text microservice that owns the single Whisper model in the stack and exposesPOST /transcribe. Shared by thesignal-gateway(inbound voice notes) and the web gateway (dashboard voice input), so exactly one ASR model is loaded system-wide.qlever-life— a live SPARQL endpoint over the shared chambers volume, served by qlever-dir (included as a submodule). Every chamber's RDF files are indexed equally; rebuilds automatically on filesystem changes. Seedocs/triple-stores.mdfor what this makes possible — querying Markdown frontmatter, sensor CSVs at scale, and why some data gets its own store. For a worked look at the named-graph half of that design, with queries run against a live store and its limits stated, see Provenance by path.
A deployment can add further services in its override — for example a
qlever-static/-based endpoint for one large, rarely-changing N-Triples file
(see docker-compose.override.example.yml).
Such SPARQL endpoints are advertised to agents through SPARQL_ENDPOINT_<NAME>
(and optional SPARQL_ENDPOINT_<NAME>_DESC) environment variables on the
retinue service, with their hostnames listed in SPARQL_NO_PROXY (in .env)
so queries bypass the egress-audit proxy.
Your chamber data lives in separate repositories and is cloned into the shared
chambers volume at first start. Chambers to mount are declared in
chambers.json; each chamber's plugin (if any) is autodetected from its
.retinue/.claude-plugin/plugin.json, and the entrypoint generates
.claude-plugin/marketplace.json from those at startup.
- Docker with Compose
- A Claude.ai account (used to log in to Claude Code on first start)
- A GitHub personal access token with
reposcope (for cloning and pushing the mounted chambers) - A repository for each private chamber you want to mount (declared in
chambers.json)
git clone --recurse-submodules https://github.com/retinue-os/retinue.git
cd retinue
cp .env.example .envIf you already cloned without --recurse-submodules:
git submodule update --initEdit .env and fill in your values:
ANTHROPIC_API_KEY=sk-ant-...
GITHUB_TOKEN=ghp_...
TRAEFIK_BASIC_AUTH_USERS=user:$$apr1$$...$$...
SIGNAL_ACCOUNT=+15551234567
Retinue invokes Claude Code for its interactive session, web gateway, and scheduled agent jobs. Claude Code can use a Claude-compatible endpoint in place of Anthropic. This keeps Retinue's tools, plugins, permissions, and workflows unchanged while allowing an Ollama local or cloud model to provide inference.
For Ollama, add the following to .env and select a model available from your
Ollama server or Ollama Cloud:
ANTHROPIC_AUTH_TOKEN=ollama
ANTHROPIC_API_KEY=
ANTHROPIC_BASE_URL=http://ollama:11434
RETINUE_CLAUDE_MODEL=qwen3.5ANTHROPIC_BASE_URL may instead point at any Claude-compatible gateway. The
optional RETINUE_CLAUDE_MODEL is passed as --model to every Claude Code
process Retinue starts, so dashboard conversations and scheduled jobs use the
same selected model. Claude Code remote-control sessions are tied to a
Claude.ai login and are therefore disabled when a gateway is configured. Omit
all four settings to retain the default Claude Code authentication and
remote-control session.
OpenRouter exposes a Claude-compatible Messages API. For example, to use OpenAI's GPT-4o through OpenRouter:
ANTHROPIC_AUTH_TOKEN=sk-or-v1-...
ANTHROPIC_API_KEY=
ANTHROPIC_BASE_URL=https://openrouter.ai/api
RETINUE_CLAUDE_MODEL=openai/gpt-4oKeep the OpenRouter token only in the deployment's untracked .env file.
The included litellm service supports Claude Code Pro/Max subscriptions with
OpenRouter failover. It forwards Claude Code's OAuth token only to Anthropic;
LiteLLM retains the OpenRouter key and retries there when the subscription
request fails (for example, due to a burst-rate limit or upstream error).
LiteLLM also feeds the dashboard's conversation-model picker: any route whose
model_info sets retinue_picker: true (with a retinue_label) is offered in
the picker — whether declared in litellm/config.yaml (which seeds the Claude
defaults) or added at runtime through the LiteLLM admin UI. No separate model
list needs configuring.
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
ANTHROPIC_BASE_URL=http://litellm:4000
ANTHROPIC_CUSTOM_HEADERS=x-litellm-api-key: Bearer sk-retinue-...
RETINUE_CLAUDE_MODEL=retinue-claude
RETINUE_GATEWAY_USES_CLAUDE_OAUTH=true
LITELLM_MASTER_KEY=sk-retinue-...
LITELLM_PRIMARY_MODEL=anthropic/claude-opus-5
LITELLM_FALLBACK_MODEL=openrouter/anthropic/claude-sonnet-4
OPENROUTER_API_KEY=sk-or-v1-...After starting the stack, run docker compose run --rm retinue interactive,
start claude, and choose Claude account with subscription to authorize
the container. The OAuth token stays in the persistent retinue-root volume.
The LiteLLM Admin UI is available at https://litellm.<your-domain>/ui when a
deployment routes the litellm service through Traefik. It uses the same
client-certificate/basic-auth middleware as the Retinue dashboard. Its
PostgreSQL database is internal-only and stores LiteLLM configuration and logs
— and, with store_model_in_db enabled, the models added through the admin UI,
including any provider API keys they carry (encrypted with LITELLM_SALT_KEY,
falling back to the master key; see .env.example).
Any chamber repository URLs your deployment clones are supplied via
chambers.json (a url, or a url_env naming an environment variable you set
here) — see Deployment.
Generate TRAEFIK_BASIC_AUTH_USERS with an htpasswd-compatible hash (for
example via htpasswd -nb <user> <password> and doubling $ signs when
copying into .env). This guards only the public gateway; container-to-container
access on the internal Docker network still reaches the backend retinue service
directly without authentication.
The public router accepts either a TLS client certificate or the basic-auth password above. Install a certificate in your browser and you skip the password prompt entirely. Mechanics:
- Traefik verifies any presented client certificate against a small client CA and
forwards it to the gateway; the gateway's
/authendpoint authorizes on a valid certificate and otherwise falls back to the basic-auth prompt. Certificates are optional (VerifyClientCertIfGiven), so existing password access is unchanged. - Issue a browser
.p12withscripts/gen-client-cert.sh(creates the CA on first run). Optionally pin the certificate's CN viaGATEWAY_CLIENT_CERT_CN. - One-time Traefik wiring (a file-provider TLS option + the client CA) is described
in
deploy/traefik/README.md— TLS options cannot be set through Docker labels, so they live there.
A messaging account (a Signal number, a linked WhatsApp device, or a Telegram
bot) has exactly one purpose, fixed by configuration and never inferred from a
message's content. Set it with SIGNAL_GATEWAY_MODE (Signal),
WHATSAPP_GATEWAY_MODE (WhatsApp), or TELEGRAM_GATEWAY_MODE (Telegram):
| Mode | The account is… | Inbound handling | Reply to sender? |
|---|---|---|---|
control |
a control channel for operating Retinue (the classic gateway) | run as a prompt to Ara | yes — voice/text on the same channel |
inbox (default) |
one of the user's own message sources, like an e-mail inbox | forwarded to triage as the user's incoming mail, surfaced on the dashboard as a push notification | no |
The default is inbox, so an account left unconfigured cannot drive the
system — exposure defaults closed. Turning an account into a control channel is
an explicit opt-in (SIGNAL_GATEWAY_MODE=control) and still requires the sender
to be on the accepted-requesters allowlist (below). Because the mode is a
property of the account, the triage skill never has to decide whether an incoming
message is a system instruction or user mail.
Each account must be authenticated once with signal-cli before its gateway can
send or receive. Run the command against the account's service so it writes into
that service's signal-data volume (below), where it then persists:
-
A number you control exclusively (e.g. Ara's own control number) — register it, then confirm with the SMS/voice code:
docker compose run --rm signal-gateway signal-cli -a +15551234567 register docker compose run --rm signal-gateway signal-cli -a +15551234567 verify 123456
-
The user's existing Signal account (typical for an
inboxsource) — link the gateway as a secondary device:docker compose run --rm signal-gateway signal-cli -a +15557654321 link -n retinue
This prints a
sgnl://linkdevice?...URI; render it as a QR code and scan it from the phone's Signal app under Settings → Linked devices.
For an account added in the override, use its service name in place of
signal-gateway (e.g. signal-gateway-personal).
Each gateway runs one signal-cli instance,
which keeps that account's registration — identity keys, the linked-device
session, and delivery state — under /root/.local/share/signal-cli. The base
compose mounts the named signal-data volume there so this state survives
container restarts; without it you would have to re-link the account (see
Linking a Signal account) after every restart.
Every account needs its own volume. Do not point two gateways at the same
signal-data volume: signal-cli takes an exclusive lock on the data directory,
so two processes sharing it would collide, and the second account's keys would
overwrite the first. When you add an account (below) you give its service a
fresh named volume mounted at the same path.
The base signal-gateway service is account #1, configured from .env
(SIGNAL_ACCOUNT, SIGNAL_GATEWAY_MODE, …). To add further accounts, declare
one extra service per account in your deployment's docker-compose.override.yml
— the same file that carries the rest of your deployment wiring. Each extra
service reuses the base image via extends, then overrides just what differs:
its own SIGNAL_ACCOUNT, its SIGNAL_GATEWAY_MODE, and its own named
signal-data volume.
For example, to keep Ara's control number as account #1 (in .env) and add the
user's personal number as an inbox source:
# docker-compose.override.yml
services:
# Account #2: the user's personal number, an inbox source that feeds triage.
signal-gateway-personal:
extends:
file: docker-compose.yml
service: signal-gateway
environment:
- SIGNAL_ACCOUNT=+15557654321 # this account's own number (E.164)
- SIGNAL_GATEWAY_MODE=inbox # forward to triage, no reply
volumes:
- signal-data-personal:/root/.local/share/signal-cli # its OWN volume
- piper-data:/models
networks:
- agents
volumes:
signal-data-personal:extends inherits the build, ports, and remaining environment from the base
service; the block above only names what is different for account #2. Add as
many such services as you have accounts — a full example is in
docker-compose.override.example.yml.
The whatsapp-gateway and telegram-gateway services follow the same
one-service-per-account shape (each with its own *_GATEWAY_MODE and its own
data volume).
WhatsApp is reached through the sibling whatsapp-gateway service, which owns a
linked-device WhatsApp Web session (via the neonize/whatsmeow bridge) the same
way signal-gateway owns a signal-cli account. It has the identical control
vs inbox mode model (WHATSAPP_GATEWAY_MODE, default inbox), the identical
email-style outbound send-control (WHATSAPP_SEND_POLICY, keyed — like
EMAIL_SEND_POLICY — by the sending identity, this gateway's own
WHATSAPP_ACCOUNT number, not the recipient; an undeclared account defaults to
verify), and surfaces its pending sends on the same /sends approval page.
Link the device once: start the
service and scan the pairing QR — shown on the dashboard's
/gateways page and printed to
the service logs (docker compose logs -f whatsapp-gateway) — from the phone
under Settings → Linked devices. The session persists in the whatsapp-data
volume.
Agents send with scripts/whatsapp-push.py and resolve contacts with
scripts/whatsapp-contacts.py.
Telegram is reached through the sibling telegram-gateway service, which logs in
as the user's own Telegram account — an MTProto user client (Telethon), not a
bot. Acting as the user is what makes it fit for purpose: it can message any of
the user's contacts as them, read the user's own incoming DMs (so inbox mode
genuinely triages the user's Telegram mail), and enumerate the real contact
directory — the same account access an MCP user client has, but with the
credentials isolated in the container. Same control/inbox mode model
(TELEGRAM_GATEWAY_MODE, default inbox) and the same email-style send-control
(TELEGRAM_SEND_POLICY, keyed by the sending identity — this account,
TELEGRAM_ACCOUNT, defaulting to the account's @username/phone; since it is the
user's own account the fail-safe default means every send needs approval unless
granted), surfaced on the same /sends page.
Set it up once:
-
Create an MTProto app at my.telegram.org → API development tools; put the
api_id/api_hashinTELEGRAM_API_ID/TELEGRAM_API_HASHand the number inTELEGRAM_PHONE. -
Log in interactively (writes the session into the
telegram-datavolume; prompts for the code Telegram sends, and the 2FA password if set):docker compose run --rm -it telegram-gateway python3 /app/telegram-gateway.py login
Thereafter the service starts non-interactively from the stored session. Agents
send with scripts/telegram-push.py and resolve chats with
scripts/telegram-contacts.py (recent chats first, contact directory as
fallback). Session state persists in the telegram-data volume.
Each new account must be linked once before it can send or receive — see Linking a Signal account.
Outbound pushes are addressed to a gateway service, not to an account
number: each signal-gateway service owns one account and exposes its own
/send endpoint on the agents network at http://<service-name>:8090/send.
So the choice of "which account do I send from" is the choice of which
gateway's send URL you post to:
-
Default —
scripts/signal-push.pyposts toSIGNAL_GATEWAY_SEND_URL(defaulthttp://signal-gateway:8090/send, i.e. account #1). This is the right identity for system messages (alerts, escalations, daily briefings). -
A specific account — point the client at that account's service with
--url, e.g. to send from the personal account added above:scripts/signal-push.py --url http://signal-gateway-personal:8090/send \ --recipient +15551112222 "…"
--recipient chooses who receives the message (defaulting to
SIGNAL_DEFAULT_RECIPIENT); --url chooses which account it comes from.
Outbound Signal messages are gated by a policy that mirrors EMAIL_SEND_POLICY
exactly. As with e-mail, the category is keyed by the sending identity — this
gateway's own SIGNAL_ACCOUNT number — not the recipient: what governs
whether an agent may post autonomously is which number the message goes out as,
not who it is addressed to. (Who may message in to drive the system is the
separate inbound control — the accepted-requesters allowlist, control mode only.)
SIGNAL_SEND_POLICY is a JSON array of {number, category} entries keyed by the
sending account (E.164 number, "*" as a wildcard default):
allow— send directly, no confirmation (e.g. a dedicated agent number).trust— send directly only whensignal-push.pypasses--user-approved(used when the user explicitly requested the send in a conversation); otherwise the send falls back to theverifyflow.verify— register the message as a pending send; it is transmitted only after explicit approval on the web gateway's/sendspage. An agent can never approve its own send.
An account matching no entry and no "*" wildcard falls back to verify
(fail-safe, same default as e-mail), so an undeclared account can never post
autonomously. So: a dedicated agent number (say Ari's) can be allow while the
user's own linked number stays verify.
# Recipients matched by a verify/trust policy return a pending-approval notice
scripts/signal-push.py --recipient +15551112222 "Draft reply to review"
# → signal-push: send queued for approval (id=…)
# signal-push: approve or deny at https://agents.example.com/sends/signal-gateway/…Pending Signal sends appear on /sends alongside e-mail approvals; the
web-gateway fetches them from the signal-gateway's token-gated /pending-sends
API (SIGNAL_GATEWAY_BASE_URL) and proxies the allow/deny action back to it.
Approval is asynchronous on all messenger gateways: the gateway answers
status: sending immediately and delivers in the background, and the approval
page live-refreshes until the terminal status — so a slow send (a large
document upload, a first-contact device-list lookup, Signal's voice synthesis)
shows sending → sent, or the gateway's real error string on failure, instead
of tripping the proxy timeout into a misleading "gateway unreachable".
SEND_APPROVAL_BASE_URL sets the public host used to build the approval link
returned to the caller. The /sends/<slug>/… segment is the gateway's own
Docker service name — derived on the gateway from the request's Host header
and on the web-gateway from the hostname of the registered base URL, so the two
always agree and an extra account (e.g. signal-gateway-personal, enrolled via
MESSENGER_GATEWAYS) gets working approval links with no slug configuration.
Sending a message first requires resolving a name (e.g. "Jane Doe") to a
number — the contact-lookup step. The gateway exposes the account's own roster
for this over three token-gated GET endpoints on the agents network:
GET http://<service-name>:8090/recent-chats→{"recent_chats": [{number, uuid, name, last_seen}, …]}GET http://<service-name>:8090/contacts→{"contacts": [{number, uuid, name}, …]}GET http://<service-name>:8090/groups→{"groups": [{id, name}, …]}
/contacts and /groups are backed by signal-cli listContacts / listGroups
and serialized through the same lock as the receive loop, so they never race it.
/recent-chats is different: signal-cli keeps no queryable message history, so
the gateway builds its own — it records each inbound sender (identifiers, the
envelope's name, a last-seen time) as messages arrive, most-recent-first,
persisted on the pending-sends volume. This is the gateway's stand-in for
"recent conversations".
The messaging-contact-lookup skill
mandates recent conversations first, the contact directory only as a
fallback. scripts/signal-contacts.py (mirrors signal-push.py: --url
picks the account/gateway, SIGNAL_GATEWAY_TOKEN authorizes) implements exactly
that order — a name query hits /recent-chats first and only falls back to
/contacts on a miss. Each result carries a source field so the caller knows
which layer answered:
# Resolve a name: recent chats first, directory as fallback (the default)
scripts/signal-contacts.py --url http://signal-gateway-personal:8090 --query doe
# → [ { "number": "+15551112222", "uuid": "…", "name": "Jane Doe",
# "last_seen": 1752…, "source": "recent-chats" } ]
scripts/signal-contacts.py --query doe --contacts # force the directory, skip recent
scripts/signal-contacts.py --all # dump recent chats
scripts/signal-contacts.py --all --contacts # dump the contact directory
scripts/signal-contacts.py --groups # list groups on account #1This is what lets an agent look up a contact and send in one flow. The gateway is the sole Signal contact path and works in scheduled/headless sessions. The endpoints are read-only — they never send.
In control mode the gateway only answers requests from allowlisted
requesters when on-behalf-of is present. Any chamber may contribute an
accepted-requesters.txt at its root (one requester identity URI per line);
the gateway unions them all. Empty
lines and # comments are ignored; comma-separated values on a line are
supported. For phone-number identities, use tel: URIs (for example
tel:+15551234567). The Signal gateway sends sender numbers as tel:
URIs in on-behalf-of; requests without on-behalf-of skip this check. If no
allowlist entry exists, requests with on-behalf-of are rejected. To use a
single explicit file instead, set ACCEPTED_REQUESTERS_PATH in .env.
inbox accounts do not use the allowlist: their messages are the user's own
incoming mail, processed under the owner's session and never run as prompts, so
the external sender is never treated as an authorised requester.
Linked-device sessions die silently: the phone unlinks the device, a Telegram session gets revoked — and without monitoring nobody notices until a correspondent complains that their messages go unanswered. Three pieces close that gap:
- Honest health. Every messenger gateway's
GET /healthreports its real link state (connected, plus anerrorexplaining why not): Signal derives it from the receive poll loop, WhatsApp from the bridge's connected/logged-out events plus a periodic info-query (usync) probe — the bridge can hold a live socket while outbound IQ queries are wedged, in which case every send to a recipient without a cached device list fails, soconnectedmeans "can actually send", not "socket is open" (WHATSAPP_IQ_PROBE_SECONDS, default 60;WHATSAPP_IQ_PROBE_FAILURES, default 2; on a sustained wedge the gateway also tears its connection down to force a reconnect, at most once perWHATSAPP_IQ_RECONNECT_BACKOFFseconds, default 600) — and Telegram from the MTProto connection plus a periodic session probe (which catches a session revoked from another device). An unconfigured channel reportsconfigured: falseand idles instead of crash-looping. - The monitor.
scripts/gateway-monitor.py(forked by the entrypoint in theretinuecontainer) polls every configured gateway's/healthonce a minute. After two consecutive failures it notifies the user through the existing inbound-message mechanism — a dashboard conversation, which Web- Pushes the user's devices like any incoming message — linking to the re-pairing page. It reminds every 6 h while the outage lasts and reports the recovery in the same thread. Tunables (all optional):GATEWAY_MONITOR_INTERVAL,GATEWAY_MONITOR_FAILURES,GATEWAY_MONITOR_REMIND_SECONDS,GATEWAY_MONITOR_IGNORE(comma-separated slugs to skip, e.g. a deliberately unlinked channel). It watches the same registry/sendsuses — the built-in channels named inMESSENGER_BUILTIN_CHANNELS(default: all three) plus anyMESSENGER_GATEWAYSextras. - Re-pairing from the phone. The dashboard page
/gateways(behind the same edge auth as the rest) shows each gateway's live state and, for a disconnected one, the pairing QR code — proxied from the gateway's token-gatedGET /qrendpoint, so nodocker logsordocker cpis needed. WhatsApp serves the QR its bridge emits when unlinked; Signal starts asignal-cli linkattempt on demand (device nameSIGNAL_DEVICE_NAME, defaultretinue); Telegram runs Telethon's QR login (a 2FA-protected account additionally needsTELEGRAM_2FA_PASSWORD, or the one-time interactive login above).
Builds the image, mounts the chambers into the container, and drops into an interactive shell so you can authenticate Claude Code:
docker compose run --rm retinue interactiveInside the container, run:
claudeFollow the prompts to log in and trust the /workspace folder. Once done, exit
the shell (Ctrl-D).
After the first-time setup, start the system in remote-control mode (detached, auto-restarts on failure or reboot):
docker compose up -dTo stop it:
docker compose downClaude Code will be reachable via the Claude.ai interface or any configured remote-control client.
- Each chamber declared in
chambers.jsonis made available at/workspace/chambers/<name>, inside the sharedchambersvolume. If that directory already has content — a previous clone, or a directory the deployment mounts there — it is used as-is; otherwise the entrypoint clones the chamber'surl, or symlinks a localpath. The basedocker-compose.ymlmountschambers.example.jsonby default, booting the two example chambers. Deployments override this mount indocker-compose.override.yml. - Each chamber that carries a plugin (
.retinue/.claude-plugin/plugin.json) is autodetected; the entrypoint generates.claude-plugin/marketplace.jsonfrom the identity template and installs the plugins, making their subagents available in every session. - Python dependencies from every chamber's
requirements.txt(if present) are installed in theretinuecontainer. - The
qlever-lifeservice indexes every.nt/.ttl/.n3file in the shared chambers volume — plus any extension a chamber declares a converter for in.qlever/converters.json, which is how Markdown frontmatter becomes queryable — all chambers equally, and serves it onqlever-life:7001(network; publish a host port via the deployment override). It watches for filesystem changes and rebuilds blue-green; new data is queryable in tens of seconds (measured 15–25 s across six rebuilds of a small chamber, 2026-07-19 and 2026-07-25 — it grows with the chamber, so measure your own if it matters). Note that only a change to a native RDF file currently starts that clock: a Markdown edit waits for an unrelated RDF change or a restart (qlever-dir#3). - Git hooks are installed in every chamber that is a git repository.
- For every chamber with a
.refresh.json, the background refresh dispatcher is started (seescripts/refresh.py). - The selected mode launches (
interactiveorremote-control). - The
signal-gatewayservice polls Signal messages and sends spoken replies, and serves the outbound/sendendpoint for agent-initiated pushes.
This framework is content-neutral. A deployment supplies its own chambers and edge wiring without forking the framework:
- Provide a
chambers.json(content-only:name, optionalurl/url_env, or a localpath). Seechambers.example.jsonand the example chambers. - Copy
docker-compose.override.example.ymltodocker-compose.override.yml(Compose merges it automatically; it is git-ignored). It bind-mounts yourchambers.jsonover the shipped example and supplies the deployment-specific bits the base compose leaves out: the public Traefik router/host rule and basic-auth/client-cert middlewares, the externalwebnetwork, and any published QLever host ports.
A typical deployment lives in its own repo that pins retinue-os/retinue as a git
submodule (reproducible) and contains that override, chambers.json, and .env.
Chambers in chambers.json are normally cloned into the container on first
start. If a chamber needs host-managed, uncommitted files — typically a
project-scoped secrets file — provide it as a host bind-mount instead. The
entrypoint detects an already-present chamber (.git or non-empty directory) and
uses it as-is rather than cloning, so your working copy and its secrets stay put.
Keep this host-specific wiring out of the committed compose by using a
docker-compose.override.yml (Compose merges it automatically; it is
git-ignored):
# docker-compose.override.yml (not committed)
services:
retinue:
volumes:
- /abs/host/path/to/ari:/workspace/chambers/ari
qlever-life:
volumes:
- /abs/host/path/to/ari:/data/ari:roBoth mounts are required: retinue needs the chamber for agent code and data,
and qlever-life needs it so the chamber's RDF files are indexed by the live
SPARQL store. The qlever-life mount uses the same bind-source but targets
/data/<chamber-name> (read-only).
Then on the host:
git clone git@github.com:you/assistant.git /abs/host/path/to/ari
cd /abs/host/path/to/ari
cp .secrets.env.example .secrets.env # fill in credentials (gitignored)Because the chamber is pre-mounted, the entrypoint will not clone or pull it —
update it yourself with git pull on the host.
retinue/
CLAUDE.md ← session instructions baked into the runtime image
agents/ ← core agent role definitions (Archivist, Academic, …)
chambers.example.json ← example chamber manifest (deployment provides chambers.json)
examples/chambers/ ← runnable example chambers (Westworld, Hitchhiker)
.claude-plugin/ ← marketplace identity template (marketplace.json generated at runtime)
scripts/ ← operational scripts (ingestion, hooks, refresh)
Dockerfile ← image for the `retinue` service
signal-gateway/ ← image definition for the `signal-gateway` service
docker-compose.yml ← deployment-neutral base composition
docker-compose.override.example.yml ← deployment-specific wiring template
qlever-dir/ ← submodule: live SPARQL-over-directory service
qlever-static/ ← single-file SPARQL service (optional, deployment override)
Domain agents are not defined here: each mounted chamber ships its own
(the example westworld chamber provides .retinue/agents/dolores.md as a
Claude Code plugin).
To pick up changes to agents, scripts, or dependencies:
git pull --recurse-submodules
docker compose build
docker compose up -dYour chamber data lives in named volumes and is unaffected by rebuilds.