minimal infrastructure for secure claude code group chat
  • Go 99.4%
  • Dockerfile 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
cwitt 086f242bcd bin/maichat-arm64: rebuild at stale-prebuilt-gate commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 04:25:28 +00:00
.config/go/telemetry/local collapse the CLI to up/down; tmux session named for the host 2026-08-29 18:26:54 -05:00
bin bin/maichat-arm64: rebuild at stale-prebuilt-gate commit 2026-09-01 04:25:28 +00:00
cmd/maichat upgrade: refuse a stale prebuilt binary — the reconciliation of tonight's room-posted mirrors 2026-09-01 04:25:14 +00:00
config bare agent: the laptop role runs without a container 2026-08-29 23:13:58 -05:00
docs supervise: the ssh tunnel self-heals — auto-restart with capped backoff (5s→60s, healthy run resets); ctl stop still parks it; bridge DMs a reconnect notice after a real outage 2026-08-31 15:47:13 -05:00
internal bridge: close the silent-loss seams from the 08-31 incident night 2026-09-01 04:17:53 +00:00
prompts bridge: close the silent-loss seams from the 08-31 incident night 2026-09-01 04:17:53 +00:00
.gitignore rename project to maichat 2026-08-29 18:39:01 -05:00
CLAUDE.md upgrade: refuse a stale prebuilt binary — the reconciliation of tonight's room-posted mirrors 2026-09-01 04:25:14 +00:00
Containerfile Containerfile: add curl — agents kept hitting 'curl: command not found' on API probes 2026-08-31 13:15:19 -05:00
go.mod rename project to maichat 2026-08-29 18:39:01 -05:00
README.md upgrade: refuse a stale prebuilt binary — the reconciliation of tonight's room-posted mirrors 2026-09-01 04:25:14 +00:00

maichat

Two Claude Code agents you watch and steer entirely from a normal chat client:

  1. A dev agent on the remote dev server (permanent infrastructure).
  2. A test agent on the corporate laptop, which can reach the test/prod servers.
  3. You, via Gajim (two DMs + the shared watercooler room).

The dev server cannot reach test/prod, so the dev agent never sees real data — every observation about real systems arrives as a chat message from the test agent. That asymmetry is the point of the project.

There is no orchestration engine and no long-running agent process: each agent is a claude session run one headless turn at a time. A small bridge queues incoming chat, feeds it to claude -p --resume as the next turn's prompt, and mirrors the turn's output stream back to the watercooler — one-liners per tool call, the finished reply as the agent's message. The claude session (in ~/.claude) is the source of truth; the chat mirrors it. Token cost is the same as an idle interactive session — every turn re-sends the transcript either way, and prompt caching works on gaps between turns, not process lifetime.

The server end is one container image (prosody + the dev agent — an appliance on a box you don't otherwise groom). The laptop end is bare: one static binary running the tunnel and the bridge as plain processes, driving the claude you already have installed — no podman, no image:

┌────── laptop (test workstation) ───────┐      ┌──────── dev server ────────┐
│ Gajim ───► 127.0.0.1:5222 ─┐           │      │ maichat-server container:   │
│ maichat agent (bare):       │           │      │   prosody (127.0.0.1:5222) │
│   tunnel (ssh: 5222,5280) ─┴───────────┼─ssh──┼─► ▲                        │
│   bridge ↔ claude -p (test-agent)      │      │   bridge ↔ claude -p       │
│   (reaches test/prod)                  │      │   (dev-agent)              │
└────────────────────────────────────────┘      └────────────────────────────┘

Running it

On the dev server, once (it's permanent — the room keeps archiving while your laptop is closed):

cd ~/src/yourproject     # the dir the dev agent works in
maichat up

That builds the image on first run, starts the container (prosody + the dev-agent bridge), registers the three accounts on first boot (passwords land in ~/.local/share/maichat/accounts.env; the boot log says so), and leaves you streaming the live logs. Zero configuration.

On the laptop (no podman needed — just the binary and a logged-in claude in the same shell environment), whenever you like:

# once: the only config file in the system
mkdir -p ~/.config/maichat && cp config/agent.env.example ~/.config/maichat/agent.env
chmod 600 ~/.config/maichat/agent.env   # paste TEST_AGENT_PASSWORD from the server

cd <where the test agent should work>
maichat up --server you@devserver       # remembers the server after the first time

That spawns the agent supervisor as a detached process — the tunnel (ssh -N -L 5222 -L 5280, so prosody — chat and its HTTP file share — appears on the laptop's loopback for both the bridge and Gajim) and the test-agent bridge, which runs your installed claude one headless turn at a time. Point Gajim at human@chat.internal, hostname 127.0.0.1, port 5222, and join watercooler@conference.chat.internal.

maichat up is idempotent: nothing running → start it here (--server present or remembered = the bare agent; neither = this host is the server, in its container); running → revive a dead tunnel; always → stream the logs. Watching again after a Ctrl-C is just maichat up again.

Watching it (either end)

maichat up ends by streaming the logs — every component, prefixed, the turn-by-turn agent activity included. Ctrl-C stops the watching, never the system. The control verbs work from your normal shell on either end (on the server they forward into the container; the bare agent answers directly):

maichat ctl status                      # what's running; dead things say so
maichat ctl start|stop|restart <child>  # children: prosody|tunnel, bridge
podman exec -it maichat-server bash     # server only: prosodyctl, poking around

On the laptop there is nothing to exec into: the log files sit in ~/.local/state/maichat/log/, and maichat send works from your shell as-is.

The agent has no terminal at all: it is headless, and all steering happens in chat. Nothing auto-restarts except transport. If prosody or the bridge dies, the log says so loudly and ctl status shows it DOWN until you act. The ssh tunnel is the exception: it auto-restarts with capped backoff (its common death is laptop sleep; ctl stop tunnel still means stay down), the bridge's XMPP loop reconnects through it, and the bridge DMs you [reconnected — chat was unreachable for ...] after a real outage. maichat down stops the container / the bare supervisor (both are disposable — chat data lives in the server's data volume, claude sessions and the bridge's queue in your ~/.claude, code in your project dir).

Laptop sleep: the tunnel dies with the connection and stays down, visibly. When you're back: maichat up (or maichat ctl start tunnel). Gajim and the bridge reconnect on their own once the tunnel returns; messages that queued meanwhile run as the next turn, and MAM catches the room up on what the dev agent did while you were away.

Talking to the agents

  • DM an agent (dev-agent, test-agent) or @-mention it in the watercooler — either becomes the prompt of its next turn, tagged with origin ([dm] human: ..., [room] test-agent: ...). Messages arriving while a turn runs batch into the following turn, oldest first. Addressing is explicit: @name anywhere, or the name opening the message (the tab-completion form dev-agent: ...). A bare name mid-sentence is a reference and triggers nobody — so speak freely ABOUT the agents in the room; un-addressed chatter is delivered to each agent, tagged [room, unaddressed], as context with its next addressed turn.

  • Agent replies post to the room under the agent's nick; agents address each other the same way. Permission denials ([tool error] ...) are DM'd to you — the audit trail lives in your DM stream and the event log, and the agent is instructed to name a blocking denial in its room reply.

  • Control commands (yours alone — agents can't trigger them): /clear (fresh claude session), /interrupt (kill the running turn — its messages requeue and hold until you speak, or /drop them), /status (whether a turn is running, queue, session, budget, holds, uptime, version, model, context fill, cost since the last clear), /queue (list pending messages, numbered — answers into your DM), /drop N / /drop N-M / /drop all (delete queued messages by those numbers before they run; a message already in the running turn is refused — /interrupt first, which requeues it), /upgrade (that end pulls the repo, rebuilds and restarts itself; the fresh bridge's boot notice verifies the running version against the upgrade's target, and the bare agent REFUSES to swap in a prebuilt binary whose source lags the repo — the fix then is rebuilding bin/maichat-<arch> at HEAD and pushing), /help (this list, answered into your DM). Sent as a DM they command that one agent; typed bare in the watercooler they command every bridge — room /clear starts everyone fresh, room /status surveys both ends at once. Numbers are per-agent, so aim numeric /drop at one agent via DM; in the room, /drop all is the form that makes sense.

  • The over-the-shoulder stream arrives as DMs to you: one-liners per tool call ([tool] ...), the agent's thinking ([thinking] ..., in full), and every bridge notice — permission denials/tool errors, control-command start/progress/completion (/clear, /upgrade, holds, [status]), and a [bridge up — ...] line when a bridge (re)starts. The room carries only the conversation: agent replies and deliberate agent sends, with the typing indicator on — in the room and the DM — for as long as the turn runs. The room stays conversation-only. Thinking off with MAICHAT_THINKING=0 (it's the claude thinking budget, default 4096 tokens).

  • Attachments work both ways. Send a file from Gajim (DM or room) and the agent's next turn says where it was saved so it can Read it — screenshots of prod behavior reach the dev agent this way. Agents send files with maichat send -f <path> <to> ["caption"] (maichat-send is a container-only alias). Files ride prosody's HTTP file share on the same loopback + tunnel as chat (port 5280, 20 MB limit); inbound ones land durably in ~/.claude/maichat/files/.

  • Slash commands pass through from you only (slash text from an agent is defused by the origin tag); the control commands above are answered by the bridge itself and never reach claude. The only message drop in the system is yours, explicit and announced: /drop (after /interrupt, the interrupted batch is back in the queue and droppable).

  • Nothing in the room is lost while a bridge is down. On (re)join each bridge asks the room for history since the last message it saw and queues what it missed (tagged recovered in the agent's prompt; addressed messages run normally), announcing one [catch-up: ...] summary in your DM. Replayed control commands are skipped — a stale /upgrade or /clear must not fire late. DMs to an offline party still bounce, deliberately.

  • maichat send [-f <file>] room|human|dev-agent|test-agent "<msg>" works on both ends (agents use it for progress notes; -f attaches a file; body - reads stdin). The container also symlinks it as maichat-send.

  • The transcript viewer: every bridge keeps a full structured record of what it saw — chat, thinking, every tool call and result, replies — in ~/.claude/maichat/events/. maichat view (either end) renders it as a browsable chronological page: code edits shown as real diffs like the Claude Code TUI, long listings folded into collapsible blocks. It serves http://localhost:5281 on loopback (the tunnel forwards 5281, so the server's transcript is reachable from the laptop browser too); maichat view -o transcript.html writes a static file you can archive or hand to someone. Each end records only its own traffic, so the human↔test-agent DM stream exists nowhere but the laptop.

Safety rails (the bridge's whole job)

  • Permissions are pre-decided, never prompted. Headless turns can't ask, so an action outside the role's allowlist is denied and the denial is mirrored to your DM with that agent ([tool error] ...) — the room stays conversation-only, and the agent is instructed to name a blocking denial in its reply. Chat text can never approve anything. Defaults: the dev agent runs broad (its blast radius is the dev box, which has no prod reach); the test agent is deny-by-default apart from progress notes and reading received attachments. Tune per role with MAICHAT_CLAUDE_ARGS or per project in .claude/settings.json.
  • Turn budget — at most MAICHAT_TURN_BUDGET (default 6) non-human turns per minute; on breach the bridge holds and says so, and stays held until you send something — a runaway agent↔agent loop stops at the brake instead of idling along at budget rate forever.
  • Failures hold, loudly. A failed turn keeps its messages queued and the bridge DMs you [turn failed: ...]; nothing retries until you send a message.
  • Queued messages keep arrival order and survive restarts (they persist next to the session id in ~/.claude/maichat/).

Setup details

See docs/SETUP.md for the one-time details: podman on both machines, per-project permission allowlists, Gajim configuration, and ssh notes (agent-forwarding vs a dedicated restricted tunnel key).

Security model

SSH is the transport; TLS is deliberately off. Prosody — chat and its HTTP file share alike — binds 127.0.0.1 only (enforced in its generated config — the container uses host networking), so the laptop's tunnel and local clients are the only paths in. The laptop initiates everything; the dev server holds no credentials to the corporate side. Chat history (including whatever prod data the test agent relays) persists in the dev server's data volume — mind that boundary. Inbound chat and relayed prod content are data, not instructions (prompts/agent.md).

Development

One Go module, stdlib only. The image is the deliverable; the binary doubles as the launcher:

go test ./...
go build -o bin/maichat ./cmd/maichat        # the host-side launcher
podman build -t localhost/maichat .         # the app (maichat up does this on first run)

Updating the server (claude, node, prosody, maichat itself) = rebuild the image and recreate the container — versions there are pinned by the image, deliberately:

maichat build && maichat down --rm && maichat up

(Every verb reaches podman through flatpak-spawn --host when needed, so this works from inside a toolbox without touching a host shell.) Updating the laptop is just git pull, rebuild the binary, maichat down && maichat up --server ... — the agent runs whatever claude your environment has, which you update on your own schedule. The engine-free architecture and its invariants are documented in CLAUDE.md.