Skip to content

Argus conversational chat (Telegram + claude -p) — design & plan

Goal: Talk to Argus in natural language from Telegram. A channel in respond/both mode gets a conversational agent — capture thoughts, ask "what needs my attention", recall memory, start/triage tasks — backed by claude -p (the Claude Code subscription, no incremental API cost), which also gives us the agent loop + MCP tools for free.

Provenance: a Go port of YACT's proven conversational-agent design (zerocool/YACT docs/plans/2026-05-26-conversational-agent.md, Yact.Chat.Runner, Yact.Agent.ClaudeProc). The design is language-agnostic; the Elixir code is reimplemented in Go. Argus is a better fit than YACT was: it is the memory hub, so the prime digest + tools come straight off its APIs.


Locked decisions

  1. claude -p resume-per-turn. Each inbound message spawns claude -p; we generate the session UUID and pass --session-id <uuid> on turn 1, --resume <uuid> after. No long-lived per-chat process (YACT got bitten by orphaned claude subprocesses); survives restarts; retry once as a fresh session if a stored id is dead (expired/pruned/CLI-upgraded). Subscription, not API — the cost lever Aaron wanted.
  2. Runs host-side on .96. claude + its auth live on the .96 host (/home/aaron/.local/bin/claude, 2.1.190 — same as the Tdarr-monitor cron); the hub/memory images are distroless (no node/claude), so the turn CANNOT run in-container. A small host-side chat worker on .96 owns the claude -p call. The hub stays the single Telegram poller (getUpdates); the worker only sends replies (sendMessage) — no poller conflict.
  3. Queue via a chat_turns table. The hub's existing ingest, on a respond/both message, enqueues a pending turn (chat_id, text, session_id). The host worker claims pending turns, runs the turn, posts the reply, persists the new session_id. Decoupled, restart-safe, observable (a stuck turn is a visible row, not a silent hang — the trust-first requirement).
  4. Prime digest, re-primed. A compact (~1-2k token) Argus-state snapshot (identity + recent decisions/notes + what-needs-attention) injected via --append-system-prompt each turn, so the agent orients without being amnesiac, then calls tools for live detail.
  5. Lean MCP toolset. A small Argus chat-MCP surface (recall/save/list), NOT shell/file/git. Mutations echo-before-doing. Facts come from tools, not the model's guess.
  6. General agent first, paired later. Build the cross-project assistant (channel with no project), then paired project-scoped chat (channel.project set) falls out by swapping the digest + tool scope.

Carried-over gotchas (already solved in YACT — do not re-discover)

  • MCP servers load from ~/.claude.json per-cwd (claude mcp add); the --mcp-config flag was unreliable in CLI 2.1.x.
  • --permission-mode acceptEdits does NOT pre-authorize MCP calls — every chat tool must be in --allowed-tools explicitly or it prompts (and there's no human at the CLI to answer).
  • Bound the turn with a timeout (~120s); reap orphaned subprocesses on timeout.
  • Telegram replies are plain text — instruct the agent: no markdown/emoji.

Phasing (each phase = its own PR)

A1 — Prime digest (pure, no claude)

internal/hub (or internal/memory): ChatPrimeDigest(ctx, project) → a compact string: identity + recent decisions/notes (from memory recall) + what-needs- attention (open tasks count + top ready, recent captures). Caps sizes; bounded length. project=="" → cross-project; a project slug → that project's digest. - Tests: digest with data + the empty case.

A2 — Turn runner around claude -p (mechanics)

ChatTurn(chatID, text, opts): runs one turn through claude -p --output-format json --append-system-prompt <role+digest> with --session-id <uuid> (turn 1) / --resume <uuid> (after), a 120s timeout, and the dead-session retry. The CLI call is an injectable seam so tests use a fake; the default impl runs the real binary. Returns {reply, session_id}. - Tests (fake claude): new session generates+passes an id; resume round-trips it; the system prompt carries the digest; a dead-resume retries fresh; CLI errors propagate; timeout reaps the subprocess.

A3 — Lean chat MCP server

A tiny Argus chat-MCP (stdio or HTTP) exposing read + safe-write tools over the existing memory/task APIs: recall, save_note, what_needs_attention, list_tasks, start_task (echo-before-doing). Registered into ~/.claude.json on .96 and allow-listed in A2's --allowed-tools. - Tests per tool (handler-level).

A4 — Routing + host worker + ship

  • Migration: chat_turns queue table (chat_id, text, session_id, status, reply, error, timestamps).
  • Hub ingest: a respond/both channel's free-text message enqueues a chat_turns row (slash-commands//note still take precedence).
  • Host worker (cmd/chat-worker, runs on .96 where claude lives): claims pending turns, calls ChatTurn, posts the reply via the bot sendMessage, persists the returned session_id on the channel, marks the row done/errored.
  • Heartbeat + staleness alert (a wedged worker is visible, never silent).
  • Tests: a respond free-text message enqueues; the worker (fake claude) processes a row and records the reply + session.

Phase B — paired (project-scoped) chat

A channel with project set → prime with that project's digest + scope tools to the slug; the agent defaults to that project. Reuses A1-A4 plumbing; only the context changes (mirrors YACT's Phase C).


Open architecture question for Aaron

The host worker is a new long-running process on .96. Options: (a) a small Go binary under systemd/docker run on the host with claude mounted; (b) a host process like the Tdarr-monitor pattern. (a) is cleaner + restart-safe. Decide at A4. Everything before A4 (digest, turn runner, MCP tools) is worker-agnostic and can be built + tested first.

Sources

  • zerocool/YACT: docs/plans/2026-05-26-conversational-agent.md, lib/yact/chat/runner.ex, lib/yact/agent/claude_proc.ex.
  • Argus: internal/hub/telegram_ingest.go, telegram_channels.go (the mode field already models respond/both), telegram.go (Telegram.Send), migration 0036_telegram_channels.sql.