Quickstart

From zero to texting your coding-agent session, on Telegram, in four steps.

What hotline is

hotline is an MCP server that relays a coding-agent session to your messaging apps. It replies in short bubbles with a typing indicator, reacts with emoji, edits its own messages to show progress, and offers inline buttons when it wants you to pick one thing. Telegram is the default provider; Signal and Discord run alongside it or on their own.

It drives two harnesses: Claude Code over its experimental two-way channel protocol (claude/channel), and OpenCode over a separate HTTP+SSE adapter. Pick yours in the setup below. Details and caveats on the protocol page.

Install

brew install 1broseidon/tap/hotline

Binaries ship with each tagged release. Or build from source (Go 1.26+):

go install github.com/1broseidon/hotline/cmd/hotline@latest   # -> $(go env GOPATH)/bin/hotline

Set up Telegram

Pick your harness. The texting experience is the same either way.

  1. Get a bot token from @BotFather, then save it (once, machine-wide):

    hotline setup --telegram-token 123456789:AA…

    Run hotline setup with no flags to be prompted, hotline setup --show to see what's configured.

  2. Register the channel in the project you want to text with:

    cd your-project
    hotline init

    This installs the hotline Claude Code plugin and enables it for the project (.claude/settings.json). Add --providers telegram,signal for extra transports, --voice for a starter HOTLINE.md, --mcp-json to register a raw .mcp.json server instead.

  3. Launch Claude Code with the channel loaded:

    hotline start   # extra claude flags go after --, e.g. hotline start -- --continue
  4. DM your bot. The first message from an unknown sender returns a 6-hex pairing code. Approve it from your terminal:

    hotline pair <code>

That's it. Your session is now a Telegram chat. The pairing step is part of the access model; read Access & permissions before you hand the bot's handle to anyone.

By hand

The three commands wrap these steps:

# setup: token in the channel's .env (a real TELEGRAM_BOT_TOKEN env var wins over the file)
mkdir -p ~/.config/hotline
printf 'TELEGRAM_BOT_TOKEN=123456789:AA…\n' > ~/.config/hotline/.env
chmod 600 ~/.config/hotline/.env
# init: install the plugin, enable it for the project
claude plugin marketplace add 1broseidon/hotline
claude plugin install hotline@hotline -s project
# start: load the plugin channel
claude --dangerously-load-development-channels plugin:hotline@hotline

Claude Code gates channel registration on an allowlist of approved channel plugins; hotline isn't on it yet, so the dev-channel flag is still needed. hotline start checks the allowlist on every launch and drops the flag for plain --channels plugin:hotline@hotline the moment hotline is approved.

Prefer a raw MCP server without the plugin? hotline init --mcp-json writes this into the project's .mcp.json:

{
  "mcpServers": {
    "hotline": { "command": "hotline", "args": ["run"] }
  }
}

Raw servers always need the dev-channel flag, by name: claude --dangerously-load-development-channels server:hotline.

OpenCode has no channel protocol, so the wiring splits in two. hotline runs as a stdio MCP server that OpenCode launches for the outbound tools, and dials OpenCode's local server for your inbound texts and permission prompts.

  1. Point OpenCode at hotline. Drop an opencode.json in your project with an mcp entry that launches the hotline binary in OpenCode mode:

    {
      "$schema": "https://opencode.ai/config.json",
      "model": "your-provider/your-model",
      "permission": { "bash": "ask" },
      "mcp": {
        "hotline": {
          "type": "local",
          "command": ["hotline"],
          "environment": {
            "HOTLINE_HARNESS": "opencode",
            "HOTLINE_PROVIDERS": "telegram",
            "TELEGRAM_BOT_TOKEN": "123456789:AA…",
            "OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
            "OPENCODE_SESSION": "ses_your_pinned_session"
          }
        }
      }
    }

    HOTLINE_HARNESS=opencode selects the harness; an unknown value is rejected at startup. Keep the token out of the config with hotline setup --telegram-token instead, which writes the shared .env; a real env var wins over the file.

  2. Run OpenCode's server. hotline dials it for inbound and answers permission prompts against it:

    opencode serve   # default http://127.0.0.1:4096

    Set OPENCODE_SERVER_URL if you moved it, OPENCODE_SERVER_PASSWORD if it sits behind basic auth (empty means no auth).

  3. Pin the session. OpenCode sessions are server-wide, so set OPENCODE_SESSION to the one you are driving. Leave it empty and hotline targets the most-recently-active session and re-pins onto whichever session emits live events.

  4. DM your bot. The first message from an unknown sender returns a 6-hex pairing code. Approve it from your terminal:

    hotline pair <code>

Same providers, same access model. Permission prompts relay as See more / Allow / Deny buttons, or yes <code> by text. What gets gated is OpenCode's own permission block: leave bash auto-approved for a yolo session, or set "bash": "ask" to route each shell command through the relay. Verified against opencode 1.17.11.

The tools Claude gets

ToolWhat it does
replySend bubbles (array of short messages, paced) or text (single message, chunked past 4096 chars). Optional reply_to, files (images inline, others as documents, 50MB each), buttons (up to 12 inline options), format (text, markdownv2, html)
reactSet an emoji reaction (Telegram's fixed whitelist)
edit_messageEdit a message the bot sent, for interim progress. Edits don't push-notify
download_attachmentFetch a non-photo attachment by file_id into the inbox; returns a local path (Telegram's 20MB download cap applies)
publishHost a local artifact (a folder or a single HTML file) at a public, temporary link — a static server plus a quick tunnel, zero accounts. Public links are passcode-gated: the tool returns the link plus a 6-digit code, visitors enter it once on an unlock page (phones autofill it from the chat message), and ten wrong guesses lock the publish. A single file is served alone, never its parent directory. Exposure backend is operator-selected via HOTLINE_PUBLISH_EXPOSURE: localhostrun (default), cloudflared, or local (loopback only, ungated)
schedulecreate, list, or cancel a scheduled task. At the scheduled time the stored prompt is injected back into the session as an inbound turn, so the agent acts on it with full tool access and normal permission gating — reminders, recurring check-ins, deferred work. Recurrence: once (relative like +2m/+1h30m, or absolute), daily, weekly, every_n_hours, every_n_days
setup_loopCreate a supervised local script loop. Normal-mode loops are pending until the operator runs hotline loop approve <label>; yolo-mode loops are live immediately and the operator is notified. The tool has no self-approve flag.
setup_notifyCreate a notify source for local scripts. It returns the label, not the capability key; the operator manages keys with hotline source list and revoke.

Button taps come back as ordinary inbound messages whose content is the tapped label, verbatim. Tap authorization mirrors the inbound gate.

Voice

The channel ships with a default persona: a sharp, funny friend texting in short bubbles. A HOTLINE.md file replaces it. First hit wins: ./HOTLINE.md in the directory Claude Code runs in, then HOTLINE.md in the state dir as your global default, then the built-in voice.

<!-- HOTLINE.md -->
Terse and professional. No emoji, no exclamation marks.
One bubble unless the answer genuinely needs two.

The file is read once at startup; edit it, then restart Claude Code. hotline budgets the channel instructions at 4096 characters; the mechanics come first and the voice gets the remainder, about 2,500 characters. A longer voice is cut at a word boundary with a stderr warning. A voice changes tone only. The tool contract, inbound message handling, and the safety rules are compiled in and apply under any voice. It carries the same trust boundary as CLAUDE.md: whoever writes to the repo shapes how the agent talks.

CLI

hotline setup        # save credentials to the shared .env (run once)
hotline init         # install the hotline plugin and enable it for this repo
hotline start        # launch Claude Code with the channel loaded
hotline up           # always-on: supervised session, restarted on crash until `hotline down`
hotline down         # stop the always-on supervisor
hotline [run]        # start the MCP server + Telegram poller (default)
hotline pair <code>  # approve a pending pairing code
hotline deny <code>  # reject a pending pairing code
hotline revoke <id>  # remove an approved sender from the allowlist
hotline status       # print state-dir / token / access summary
hotline schedule     # list | remove <id> | pause <id> | resume <id> — schedules are created from chat
hotline loop         # add | list | remove | pause | resume | logs | run — supervised script loops
hotline notify       # --source <key> [--level urgent|normal|low] [message|stdin] — event from a local script
hotline source       # add <label> | list | revoke <label> — manage notify capability keys
hotline version      # print the build's version info

pair, deny, revoke, and status take --provider kind[:instance] to select which provider's state they operate on (default: telegram). Schedules are created from chat via the schedule tool; hotline schedule is the operator's view over them — list shows next fire times, remove deletes by id (or unique prefix), pause/resume is the kill-switch. Schedules live in schedules.json at the state root and are re-read live, so CLI edits apply without a restart.

Notify is the third ingress leg, event-driven from local scripts and daemons rather than a human or a timer. hotline source add <label> mints a UUIDv4 capability key (a bearer credential; every human-facing surface shows the label, never the key), and setup_notify can create the same source from chat without returning the key; revoke kills it instantly. hotline notify --source <key> runs the full gate inline — level clamp, payload sanitization, a 10-minute dedup window, a per-source token-bucket rate limit (burst 5, refill 1 per 5 minutes by default), and quiet hours (only urgent bypasses; held events release together as one digest) — before durably enqueuing. Exit codes: 0 accepted, 3 queued, 4 rejected/rate-limited, 2 usage, 1 internal. stdin is first-class, so tail -1 backup.log | hotline notify --source $KEY --level low works. Accepted events inject as kind="notify" turns, framed as an untrusted machine report — never operator instructions, and silence is a valid outcome.

Loops are supervised local script polls. hotline loop add <label> --every <dur> --cmd "<shell>" creates a pending loop in normal mode; it runs only after hotline loop approve <label>, and hotline loop deny <label> removes it. Direct trusted CLI setup can pass -y/--approve; yolo-mode setup creates loops live immediately and notifies the operator. Once approved, hotline up runs the loop eagerly at startup and then on its interval. The runner skips overlapping ticks, skips unapproved loops, enforces --timeout with a process-group kill, writes a rotated log under <state>/loops/<label>.log, and exports HOTLINE_LOOP_STATE_DIR, HOTLINE_LOOP_LABEL, and, when a source label is configured, HOTLINE_NOTIFY_SOURCE. With --notify-llm, non-empty stdout goes through the existing notify gate using the stored source label; empty stdout enqueues nothing. Without it, the script owns escalation and hotline just logs the run. hotline loop run <label> --once executes one approved registered tick in the foreground for cron/testing.

hotline up runs a self-contained supervisor (no systemd needed): the harness is restarted on any exit with exponential backoff, logs land under <state>/supervisor/, and a supervised session gains a restart tool so the paired user can say "restart yourself" from chat. HOTLINE_HARNESS picks what runs: Claude Code on a supervisor-owned pty, or opencode serve headless. One caveat until hotline is on Claude's approved-channels allowlist: the dev-channel flag's per-launch confirmation makes unattended Claude respawns park on a prompt, so fully hands-off always-on is OpenCode-first for now.

Start from a template

mission-control is our take on what makes a good texting agent: a filing system the agent keeps on disk, an operating playbook, a starter voice. Copy the folder into your project, or install it as a plugin and scaffold with one command:

claude plugin marketplace add 1broseidon/hotline
claude plugin install mission-control@hotline
# then, inside your project's Claude Code session:
/mission-control:init

Built your own setup around hotline? Open a PR with your template.

Where things live

State lives in ~/.config/hotline (or $XDG_CONFIG_HOME/hotline): tokens, access rules, transcripts, inboxes. Older installs are migrated there automatically on first run, and legacy TELE_GO_* variables keep working as fallbacks for one release. The full environment variable table is on Multiple providers.

next: Telegram in depth →