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.
← cli-chat.dev · runbook · headless send
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
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.
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 only always-on part. It stores sealed ciphertext until the recipient's device fetches it. Senders don't wait for anyone to be online.
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
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
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:
# 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
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
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.
# 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.
tar -czf - -C ~/deploy-bot . | base64 | gh secret set CLI_CHAT_BOT_STATE
- 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.
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.
# 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
What actually happens in the ~2 seconds between the shell prompt and exit 0. No step waits on the recipient being online.
[1/6]
The send argument short-circuits before any MCP server starts — this is a one-shot process, not a service.
[2/6]
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]
The bot's keypair comes off disk — this is what signs and seals the message as "Deploy Bot".
[4/6]
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]
Body encrypted to the recipient's keys, posted to the mailbox. The server stores ciphertext only.
fails → reason on stderr · exit 1
[6/6]
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
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:
The state dir wasn't found — $MESSENGER_HOME unset, secret not restored, or restored to the wrong path.
The name isn't in the bot's book (yours doesn't count). Do the one-time --key send from § 03.
Two saved contacts match the name; candidates are listed. Use more of the name in the script.
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.
Where the state lives (the dir containing users/). Default: ~/.cli-chat. Set it everywhere in CI; never rely on the runner's home.
Pick the account when one state dir holds several — by handle or name. With one account, leave it unset.
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.