- Go 99.4%
- Dockerfile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .config/go/telemetry/local | ||
| bin | ||
| cmd/maichat | ||
| config | ||
| docs | ||
| internal | ||
| prompts | ||
| .gitignore | ||
| CLAUDE.md | ||
| Containerfile | ||
| go.mod | ||
| README.md | ||
maichat
Two Claude Code agents you watch and steer entirely from a normal chat client:
- A dev agent on the remote dev server (permanent infrastructure).
- A test agent on the corporate laptop, which can reach the test/prod servers.
- 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:@nameanywhere, or the name opening the message (the tab-completion formdev-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/dropthem),/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 —/interruptfirst, 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 rebuildingbin/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/clearstarts everyone fresh, room/statussurveys both ends at once. Numbers are per-agent, so aim numeric/dropat one agent via DM; in the room,/drop allis 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 withMAICHAT_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-sendis 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
recoveredin the agent's prompt; addressed messages run normally), announcing one[catch-up: ...]summary in your DM. Replayed control commands are skipped — a stale/upgradeor/clearmust 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;-fattaches a file; body-reads stdin). The container also symlinks it asmaichat-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 serveshttp://localhost:5281on loopback (the tunnel forwards 5281, so the server's transcript is reachable from the laptop browser too);maichat view -o transcript.htmlwrites 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 withMAICHAT_CLAUDE_ARGSor 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.