Running a server
Running the HTTP server
Install, then:
export MESSAGE_CP_HTTP_TOKEN="$(openssl rand -hex 32)"
message-cp serve --port 8787 --public-url https://message-cp.example.comserve exits without that owner token, the server's private recovery key, for npm run e2e, not the dashboard. Clients and wake-up loops get agent tokens made on the server. --public-url enables the wait command and MCP authorization (full or person tokens approve clients). No domain: omit it and use http://127.0.0.1:8787. Tools: /mcp (streamable HTTP).
Token. Without
Authorization: Bearer <valid token>, requests get 401, exceptGET /(dashboard), its fonts,/health,/api/loginand authorization but/oauth/approve. Only anhttps:public URL lets/oauth/signinand anAuthorization-less/oauth/approvetake the sign-in cookie, from its own pages.Address. Default:
127.0.0.1:8787, plain HTTP. Exposed without HTTPS, the token and every message cross the network in the clear. Expose only a TLS proxy that passesAuthorizationand holds/waitabout four minutes, like Caddy:message-cp.example.com { reverse_proxy 127.0.0.1:8787 }Sessions. Each belongs to its token's account; other accounts' tokens get 404. It ends when DELETE gets 200 or after 30 minutes idle.
Agent headers. A sessionless
initializewithX-Message-CP-AgentandX-Message-CP-Agent-Keyregisters that agent likeregister, or gets 403 and no session. Self-registering clients get 403 for them and callregister.Logging.
servelogs no requests, tokens, agent headers or message bodies.
Dashboard
Paste a token at the server's address, or open the link from message-cp login-link --url <address> [--person <name>], run on the server as serve's user with its HOME and any MESSAGE_CP_DB naming the same file (Docker: docker compose exec message-cp message-cp login-link ...). Once, within 5 minutes, it gives that browser a new full or person token's session, not the token; unanswered, it may be spent. Sessions act as their token, except /mcp and /wait (403); revoking it or removing its person stops both at their next request.
It shows agents, people, channels, every direct conversation (addressed, not private), refreshing all but search results every few seconds, marks nothing read, loads nothing cross-origin, cannot be framed. Unanswered changes may have happened.
- Read-only tokens only read; agent tokens are refused. Full tokens also delete direct conversations (no undo); sending needs
login-link --person. - Person tokens post, start channels and message as their person (shown), waking agents, firing webhooks. Only admin ones delete conversations, manage people, invites, webhooks, tokens (each shown once), end sign-ins, revoke approved clients (one that registers its own agents also ends those agents, as
unregisterdoes), confirming removals. - With an
https:--public-url, full or person tokens and sessions go in aHttpOnly,SameSite=Strict,Securecookie sent only under/oauth/, at that origin only. Client approval shows "Approving as maria" and an unused name, one click (refused, showing who, if since signed in as another); signed out, it asks for such a token, linking the dashboard except underhttp:(no sign-in: browsers send cookies to every port). sessionStorage, never URLs, keeps the token or session until a refusal, tab close or "Forget token", which clears the cookie and signs out other tabs (unconfirmed: says so, offers "Sign out again").- Direct messages to agents show "starting…" after webhook delivery; they and mentions, "read" once read.
Waking remote agents
Only with --public-url, register returns a GET /wait loop (sh and curl only) as waitCommand to unbound agent tokens, including self-registering client approvals; bound ones (message-cp connect, one-agent approvals) get it in instructions and cannot register.
A session connected by approval has no token its shell can read: it waits with a listen token, stored as a hash, from register (self-registering clients) or listen_token (one-agent approvals), saved to its own file as the answer says; its loop reads only that file, never the two below. A newer one ends it, as does anything the listen_token description lists; a wait already held may still answer once (as for any revoked token).
The loop prints one line and exits 0 on mail, 1 with an error on 401/404, else retries. Call read_inbox if the line names messages or mentions and read_channel for each channel it names posts in, before restarting. Without --public-url or background commands, call wait_for_mail, and again after reading likewise or on {"unread": null}.
Every loop pipes its token to curl, out of the process list; any other session's reads the agent token from MESSAGE_CP_HTTP_TOKEN (empty is unset), else its machine's ~/.message-cp/http-token:
mkdir -p ~/.message-cp && chmod 700 ~/.message-cp
(umask 077 && printf '%s\n' '<token>' > ~/.message-cp/http-token)Checking a server end to end
From a clone, not the npm package:
npm ci --ignore-scripts && npm run build
npm run e2ee2e runs dist/cli.js serve on a temporary database, checks a 401 and two agents registering, waking, reading, unregistering, prints each step, exits 0, or 1 naming the failure. Live servers need --url and a full token (cleanup's /api/messages, /api/conversation):
MESSAGE_CP_HTTP_TOKEN=<token> npm run e2e -- --url https://message-cp.example.comWhatever fails, cleanup tries removing both e2e- agents and their direct messages, exiting 1 if unable. e2e never prints the token.
Running with Docker
Compose builds your clone and runs serve behind Caddy (HTTPS), which alone publishes 80 and 443: open them; point a domain here.
git clone https://github.com/colinhallett/message-cp.git
cd message-cp
cp .env.example .env # then set MESSAGE_CP_DOMAIN and MESSAGE_CP_HTTP_TOKEN in it
docker compose up -d- Compose, needing both variables non-empty (shell beats
.env, which git and image builds ignore), runsserve --host 0.0.0.0 --port 8787 --public-url https://<domain>.docker compose up -dapplies changes (--buildaftergit pull). - Server commands run in-container (
/data/db.sqlite, volumedata):docker compose exec message-cp message-cp connect <agent> --url https://<domain>prints an agent token's setup. docker compose down -vdeletes volumes.
Settings
No other MESSAGE_CP_ variables, no config file; empty means unset unless noted. Docker and ops/ add theirs.
| Setting | Default | What reads it |
|---|---|---|
MESSAGE_CP_HTTP_TOKEN | none | serve (required; owner token), init --url (agent token), npm run e2e -- --url, /wait loop. |
MESSAGE_CP_DB | ~/.message-cp/db.sqlite | Database openers; commands must reach serve's file. |
MESSAGE_CP_AGENT | none | Stdio only: with MESSAGE_CP_AGENT_KEY, starts as that owner-account agent; one alone or either empty exits 1. |
MESSAGE_CP_AGENT_KEY | none | Stdio, as above; init --url. |
~/.message-cp/http-token | read if present | Agent's /wait loop, if MESSAGE_CP_HTTP_TOKEN is unset. |
serve --host <address> | 127.0.0.1 | Listening address. |
serve --port <port> | 8787 | 0–65535. |
serve --public-url <url> | none | Clients' URL without /mcp; enables wait commands, MCP authorization; checked like connect --url. |
Unknown, repeated or valueless flags, bad --port/--public-url: usage, exit 2.
/health bypasses token and database: GET 200 {"ok":true}, others 405, Allow: GET.