Built by Doug Cone. Available for consulting on multi-agent infrastructure, AI integration, and production agent platforms.
Janus runs Claude Code, Hermes, OpenCode, or Codex agents from a single Docker container — each in its own tmux window, all sharing one set of MCP servers, one credential store, and one event bus. Inbound events come from Mattermost (or any source you wire in); outbound responses go back through the same channel. The cli/janus driver attaches to a specific agent's pane on demand.
The interesting part is bridging runtimes that don't speak Claude Code's channel protocol. The pattern is documented in docs/tui-mcp-bridge.md; two halves, both runtime-agnostic at their boundary:
- Input — tmux-inject. When an inbound message arrives, the
mattermost-channelMCP server types the wrapped payload into the agent's tmux pane viatmux send-keys. The TUI sees it as if the user typed it. - Output — post-turn hook. Every TUI exposes a "this turn finished, run a script" hook (
Stopfor CC,post_llm_callfor Hermes). The hook reads the assistant response and, if the agent didn't already call a Mattermost tool, posts it to the channel as a backstop.
The trick on the input side is that Hermes's Ink-based
textInputcoalesces fast back-to-back stdin into a paste buffer with a 50ms debouncer, then strips the trailing newline on flush. Sending content + Enter as a singletmux send-keyscall types the message but never submits it. A >50ms gap between the two send-keys calls forces the paste to flush so Enter lands as its ownk.returnkeypress event — the path that actually triggerscbSubmit. Small detail; the difference between a working bridge and a broken one.
CC agents continue to use the native notifications/claude/channel event when MATTERMOST_INJECT_TARGET is unset, so adopting the bridge is opt-in per agent.
┌────────────────────────────────────────────────────────────────┐
│ claude-agents container (node:22-slim + bun + uv + chromium) │
│ │
│ Shared MCP servers (one process per agent that registers): │
│ mattermost-channel/ reply + post + react + tmux-inject │
│ heartbeat/ interval+jitter OR cron schedules │
│ scrapling user-level (host bind-mounted venv) │
│ qmd user-level (host bind-mounted package) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │
│ │ tmux: health │ │ tmux: geordi │ │ tmux: mktg │ │ links │ │
│ │ Claude Code │ │ Claude Code │ │ Claude Code │ │ Hermes │ │
│ │ #health-log │ │ #project-mgmt│ │ #marketing │ │#link- │ │
│ │ wger API │ │ Planka,vault │ │ research │ │ dump │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └────────┘ │
└────────────────────────────────────────────────────────────────┘
Four example agents demonstrate the patterns:
| Agent | Runtime | What it does |
|---|---|---|
| health | Claude Code | Logs health entries (weight, BP, etc.) from a Mattermost channel via the wger API. |
| geordi | Claude Code | Project-management — Planka boards, vault notes, scheduled status checks. |
| marketing | Claude Code | Daily content generation from overnight research; cron-driven (5am M–F). |
| links | Hermes | Silent link archiver. Watches #link-dump, fetches each URL, writes a 3-line entry into an Obsidian vault, reacts :bookmark:. |
links is the worked example of the bridge pattern from the Hermes side — driven through tmux-inject for input and the post_llm_call hook for output. The other three are CC-native.
git clone https://github.com/nullvariable/janus.git
cd janus
# Configure host paths (defaults in docker-compose.yml; override via .env or shell env)
cp -n agents/health/.env.example agents/health/.env
# ...same for each agent you intend to run
# Build (HOST_USER controls the /home/<user> -> /home/node symlink for absolute
# paths in your ~/.claude.json)
docker compose build --build-arg HOST_USER=$(whoami)
docker compose up -d
# Attach to an agent
./cli/bin/janus attach linksDefaults assume your agent source projects live under ~/projects/ and your Obsidian vaults under ~/vaults/. Every host path is overridable — see docker-compose.yml for the full env-var-driven list.
- Create
agents/<name>/.mcp.jsondeclaring the MCP servers it needs (useagents/health/.mcp.jsonas the smallest reference,agents/geordi/.mcp.jsonfor one withheartbeat). - Add
agents/<name>/.env.examplelisting the env vars the.mcp.jsonreferences via${VAR}. - Add the agent to
entrypoint.sh:AGENTS[<name>]=/agents/<name>in the workdir mapCHANNELS[<name>]="server:mattermost ..."listing each MCP channel to loadAGENT_ORDERarray
- Bind-mount the source project at
/agents/<name>indocker-compose.yml.
- Create
agents/<name>/config.yaml(Hermes config). Useagents/links/config.yamlas the reference; the key bits are themcp_servers.mattermost.env.MATTERMOST_INJECT_TARGETline (set toclaude-agents:<name>somattermost-channelknows which tmux pane to inject into) and thehooks.post_llm_callentry pointing at/app/hooks/hermes-mattermost-autopost.sh. - Add
agents/<name>/.env.examplelisting the secretsconfig.yamlreferences via${VAR}(Mattermost creds, model API keys). - In
entrypoint.shaddKIND[<name>]=hermesalongside theAGENTS/AGENT_ORDERentries. The runtime dispatcher will route tobuild_hermes_cmd. - Bind-mount the source project as in step 4 above.
The architecture details are in docs/tui-mcp-bridge.md.
| Host path (default) | Container path | Purpose |
|---|---|---|
~/.claude |
/home/node/.claude |
OAuth tokens, plugin marketplace, settings |
~/.claude.json |
/home/node/.claude.json |
User-level MCP server config |
~/.config/gh |
/host-gh:ro |
gh CLI auth (staged on entrypoint, not migrated in place) |
~/projects/health-agent |
/agents/health |
health agent source project |
~/projects/geordi-agent |
/agents/geordi |
geordi agent source project |
~/projects/marketing-agent |
/agents/marketing |
marketing agent source project |
~/projects/links-agent |
/agents/links |
links agent source project |
~/vaults/geordi |
/agents/geordi/vault |
Obsidian-style vault for geordi |
~/vaults/links |
/agents/links/vault |
Obsidian links/ folder |
~/projects/planka-cli |
/opt/planka-cli |
planka-cli source (editable install at startup) |
~/.local/venvs/scrapling (+ uv-managed Python) |
same paths | scrapling venv + interpreter |
~/.bun/install/global |
/opt/bun-global |
host bun globals (qmd + hoisted node_modules) |
~/.cache/ms-playwright |
/home/node/.cache/ms-playwright |
Chromium binaries for scrapling |
All defaults are env-var-driven; see docker-compose.yml.
entrypoint.sh maintains a KIND map; agents not listed default to claude:
declare -A KIND=(
[links]=hermes
)build_agent_cmd dispatches to build_claude_cmd or build_hermes_cmd accordingly. CC agents get auto-acceptance for first-run prompts (workspace trust, dev channels); Hermes agents skip that since it has no equivalent.
declare -A CHANNELS=(
[health]="server:mattermost"
[geordi]="server:mattermost server:heartbeat"
[marketing]="server:mattermost server:heartbeat server:heartbeat-keywords"
)Each entry produces one --dangerously-load-development-channels flag. Channels listed here MUST also be registered in the agent's .mcp.json. Ignored for Hermes agents (Hermes loads MCP servers directly from config.yaml).
declare -A MODELS=(
)Empty by default; add [<agent>]="<model-id>" to pin a non-default Claude model. Hermes agents pin their model in config.yaml instead.
heartbeat/ injects file contents into a CC agent's session on a schedule. One MCP process per agent runs N schedules, declared in a JSON file pointed at by HEARTBEAT_CONFIGS_FILE.
"heartbeat": {
"command": "/home/node/.bun/bin/bun",
"args": ["/app/heartbeat/server.ts"],
"env": {
"HEARTBEAT_CONFIGS_FILE": "/app/agents/<name>/heartbeats.json"
}
}Schedules file (agents/<name>/heartbeats.json):
[
{
"label": "create-content",
"file": "/agents/marketing/workspace/tasks/create-content.md",
"cron": "0 5 * * 1-5",
"autopost": true,
"marker": true
},
{
"label": "geordi",
"file": "/agents/geordi/HEARTBEAT.md",
"interval_minutes": 30,
"jitter_minutes": 2
}
]Each entry is one schedule. Set either cron (wall-clock aligned) or interval_minutes + jitter_minutes (drift across restarts). 5-field cron with *, ranges, lists, and */n steps. No L/W/#/? extensions. dom + dow are OR'd vixie-style when both are restricted.
Each tick emits <channel source="heartbeat" label="..." file="..." ts="..."> containing the file's content; the file content IS the prompt. label identifies which schedule fired.
The optional autopost / marker flags are read by hooks/mattermost-autopost.sh: autopost lets a heartbeat-triggered turn forward its assistant response to Mattermost (otherwise heartbeat turns stay silent); marker appends _(auto-forwarded)_ when the hook does. Per-schedule, not per-agent.
| Tool | When to use |
|---|---|
reply(channel_id, text) |
Respond to an inbound Mattermost message. |
post(text) |
Proactively post to the server's configured default channel. |
react(post_id, emoji) |
Add an emoji reaction without replying. Used by links. |
reply and post reject empty/whitespace text — if you have nothing to say, stay silent. Set MATTERMOST_INJECT_TARGET=<session>:<window> on the MCP env to drive a non-CC TUI through tmux instead of CC channel notifications.
The host's ~/.claude.json is bind-mounted, so any user-scoped MCP servers (e.g. scrapling, qmd) defined there try to launch in every agent. To suppress a user-level server for one agent, add to that agent's .claude/settings.local.json:
{
"deniedMcpServers": [
{ "serverName": "qmd" },
{ "serverName": "scrapling" }
]
}The key is deniedMcpServers (not disabledMcpServers — that's not in CC's schema). Denylist takes precedence over allowlists.
Per-project plugin enablement lives in each agent's .claude/settings.local.json:
{
"enabledPlugins": {
"hindsight-memory@hindsight": true
}
}Plugin code comes from ~/.claude/plugins/ (already mounted via ~/.claude). Two extra steps to make plugins actually work in-container:
- Per-project install registration. Enabling in
settings.local.jsonisn't enough — the plugin must also be installed for the agent's project path. Attach to the agent's tmux window and run/plugins→ Install. This writes aprojectPathentry to~/.claude/plugins/installed_plugins.json. - Local services the plugin talks to. Plugin host configs often use
127.0.0.1which doesn't reach the container. Workaround: attach Janus to the service's Docker network (external: trueindocker-compose.yml) and bind-mount a janus-specific config file over the plugin's expected path. Seeagents/geordi/hindsight-claude-code.jsonfor a worked example.
A small zero-dep Node CLI in cli/ wraps the common docker+tmux operations.
cd ./cli && npm link # adds `janus` to $PATH
# or run directly: ./cli/bin/janus <command>| Command | What it does |
|---|---|
janus list |
List agent windows (index + name). |
janus attach [agent] |
Attach to the tmux session, optionally jumping to <agent>. |
janus send <agent> <msg> |
Send a single-line message into <agent>'s prompt without attaching. |
janus help |
Show help. |
Planned: janus restart, janus update, janus status.
Inside an attached session: Ctrl-b d detaches (agents keep running); Ctrl-b n/p cycle windows; Ctrl-b 0..3 jump to window by index.
- OpenCode + Codex bridges —
opencode.mdhas the integration notes; only the post-turn hook is left to write (the input side is already runtime-agnostic). - Container ops — nightly auto-update, CLI stubs (
restart/update/status). - Mattermost — file/image attachment support.
MIT — see LICENSE.
