← cli-chat.dev · runbook · headless send

npx cli-chat-mcp send ops "deploy landed"

Pipelines, cron jobs and build servers message you like a person would. No agent, no MCP session, no daemon — one process, one sealed message, exit 0.

This is the full setup, end to end. Fifteen minutes once, then it's one line in any script forever. Needs Node 22+, nothing else.

## 00 · the model

Three things. Only one is always on.

The trick to headless send is that “logged in” isn’t a session — it’s files on disk. Once you see the three parts, the rest of this page is obvious.

The account = a directory

A keypair, a contact book, a local history cache — files under ~/.cli-chat. Any process that can read them can send. That directory is the login.

The mailbox = one HTTPS API

The only always-on part. It stores sealed ciphertext until the recipient's device fetches it. Senders don't wait for anyone to be online.

MCP = one of two doors

Your coding agent talks to the same files through MCP. send is the other door: same codebase, no agent attached. Neither door runs when you're not using it.

So the question "is the MCP connected to my terminal?" dissolves: nothing is resident. A pipeline sends by being a process with (a) the state directory and (b) a network route to the mailbox. That's the whole theory — now the practice.

## 01 · zero setup

Rung one: your own machine messages you.

Already using cli-chat on this machine? Then it's already provisioned. Any script can message you right now.

# from cron, a Makefile, a git hook, an && chain — anything:
npx cli-chat-mcp send me "nightly backup finished — 42 GB"

me is the built-in note-to-self address. The message lands in your own inbox and surfaces in your next agent session like any other. A long build that pings you when it's done is one && away:

./deploy.sh && npx cli-chat-mcp send me "deploy done ✅" || npx cli-chat-mcp send me "deploy FAILED 🔴"

That's the whole feature on a machine that already has your account. The rest of this runbook is for machines that don't — CI runners, build servers, that Raspberry Pi in the closet.

## 02 · one-time, interactive

Mint the bot its own account.

A bot is not special — it's just another account: its own email, its own keypair, its own 6-char handle. You create it once, interactively, on any machine you like (your laptop is fine).

The only step that can't be headless is this one — login is a magic link, and someone has to click it. Park the bot's state in its own directory with MESSENGER_HOME so it never collides with your personal account:

your laptop · once
# open your coding CLI with the bot's own state dir
MESSENGER_HOME=~/deploy-bot claude

# then, in the session:
 log in as ci@yourteam.dev — name it "Deploy Bot"
# click the magic link that lands in that inbox — done.
# ~/deploy-bot now holds the bot's keypair, contacts and handle.

Any address you can read works — a plus-address like you+ci@… is fine. The bot's display name ("Deploy Bot") is what recipients see on every message, so pick a good one.

## 03 · by handle, once

First contact: send to a handle.

The bot's contact book is empty. The first message targets your 6-char handle — ask your own agent "what's my handle?" if you've forgotten it.

# --key resolves the handle, sends, AND saves the contact
MESSENGER_HOME=~/deploy-bot npx cli-chat-mcp send ops --key qL7fA "deploy bot reporting in"
sent 71a84de1… to ops

That one send did two things: it delivered, and it wrote "ops" into the bot's contacts.json. From now on the name alone is enough — the handle never appears in your pipeline config:

MESSENGER_HOME=~/deploy-bot npx cli-chat-mcp send ops "works by name now"

Add the whole team the same way, one --key send each. And save the bot in your book too — say "add deploy-bot, code is <its handle>" to your agent — so its messages walk straight in, under a name you chose. (A sender you haven't saved is held at the door: the bot's first ping would sit as a 🆕 new-handle notice until you say add.)

## 04 · ship the directory

Wire it into the pipeline.

The state directory is the account, so “deploying the bot” means getting that directory where the pipeline runs. Persistent box: copy it once. Ephemeral runner: pack it into a secret.

build server, office mac, raspberry pi — anything with a disk
# copy the state dir over once…
scp -r ~/deploy-bot buildbox:~/deploy-bot

# …then any script on that box is one line:
MESSENGER_HOME=~/deploy-bot npx cli-chat-mcp send ops "deploy landed ✅"

Nothing to keep running, nothing to restart. The directory sits there; each send is a fresh process that reads it and exits.

pack the state into a secret · once, on your laptop
tar -czf - -C ~/deploy-bot . | base64 | gh secret set CLI_CHAT_BOT_STATE
.github/workflows/deploy.yml
- name: Notify ops
  if: always()   # message on failure too
  env:
    MESSENGER_HOME: ${{ runner.temp }}/cli-chat
    STATE: ${{ secrets.CLI_CHAT_BOT_STATE }}
  run: |
    mkdir -p "$MESSENGER_HOME"
    echo "$STATE" | base64 -d | tar -xzf - -C "$MESSENGER_HOME"
    npx -y cli-chat-mcp send ops "deploy of ${{ github.sha }}: ${{ job.status }}"

The runner is born, restores the account from the secret, sends, and dies. Exit 1 on any failure — drop the if: always() guard if you'd rather a failed notify fail the job.

.gitlab-ci.yml · CLI_CHAT_BOT_STATE as a masked CI variable
notify:
  stage: .post
  when: always
  script:
    - export MESSENGER_HOME="$CI_BUILDS_DIR/cli-chat"
    - mkdir -p "$MESSENGER_HOME"
    - echo "$CLI_CHAT_BOT_STATE" | base64 -d | tar -xzf - -C "$MESSENGER_HOME"
    - npx -y cli-chat-mcp send ops "pipeline $CI_PIPELINE_ID on $CI_COMMIT_REF_NAME finished"

Same shape as the Actions recipe: restore, send, done. Set the variable with tar -czf - -C ~/deploy-bot . | base64 pasted into a masked CI/CD variable.

crontab -e · the server checks in every morning
# 07:30 daily — disk check that messages instead of emailing root
30 7 * * *  MESSENGER_HOME=/home/svc/deploy-bot npx -y cli-chat-mcp send ops "$(df -h / | tail -1 | awk '{print "disk: " $5 " used"}')"

Anything that can produce a string can be a message body — command substitution, log tails, jq over a status endpoint.

## 05 · under the hood

Anatomy of one send.

What actually happens in the ~2 seconds between the shell prompt and exit 0. No step waits on the recipient being online.

[1/6]

Dispatch

The send argument short-circuits before any MCP server starts — this is a one-shot process, not a service.

[2/6]

Resolve the sender

Which account? $MESSENGER_USER if set, else the state dir's default, else the only account present.

fails → no account on this device · exit 1

[3/6]

Load the identity

The bot's keypair comes off disk — this is what signs and seals the message as "Deploy Bot".

[4/6]

Resolve the recipient

Name → the bot's own contact book (partial match, like write niels). With --key, the handle resolves online and the contact is saved for next time.

fails → no_contact / ambiguous (candidates listed) · exit 1

[5/6]

Seal & deliver

Body encrypted to the recipient's keys, posted to the mailbox. The server stores ciphertext only.

fails → reason on stderr · exit 1

[6/6]

Report & exit

Prints sent <id> to <name>, queues the message for the bot's own encrypted history, exit 0.

On your side it's just a message: it surfaces in your next session, streams into live chat, and your assistant can even answer the bot in auto chat — a pipeline that asks "tests red on main — rerun or page Sam?" is having a conversation, not writing a log line.

## 06 · field notes

When it fails, it says so.

Never a prompt, never a hang: exit 0 with a message id, or exit 1 with a reason on stderr. The ones you'll actually meet:

no account on this device

The state dir wasn't found — $MESSENGER_HOME unset, secret not restored, or restored to the wrong path.

send failed: no_contact

The name isn't in the bot's book (yours doesn't count). Do the one-time --key send from § 03.

send failed: ambiguous

Two saved contacts match the name; candidates are listed. Use more of the name in the script.

send failed: not_found

The handle in --key doesn't resolve — typo, rotated, or the owner switched their handle off (requests-only). Already-saved contacts keep working either way.

MESSENGER_HOME

Where the state lives (the dir containing users/). Default: ~/.cli-chat. Set it everywhere in CI; never rely on the runner's home.

MESSENGER_USER

Pick the account when one state dir holds several — by handle or name. With one account, leave it unset.

--as-assistant

Marks the send as coming from an assistant rather than the account's human. Good manners for bots that share an account with a person.

Treat the state directory like a deploy key. Whoever holds those files can send as the bot — keep the tarball in a masked secret, not in the repo. And give the bot its own account rather than shipping your personal one into CI: contacts scope what it can reach, and you can retire it without touching your own identity.

$cli-chat send ops "deploy went out" exit 0 · sealedone-shot