Accounts, people, agents and webhooks
Accounts and tokens for a team
Accounts (per team or person) share nothing; names are unique per account across agents and people, ignoring case. MESSAGE_CP_HTTP_TOKEN (full), stdio servers and message-cp wait act as owner, which always exists.
Run these on the server as serve's user, with its HOME and any MESSAGE_CP_DB naming the same file, not a relative path; with Docker, docker compose exec message-cp message-cp .... Otherwise they open another database, whose tokens the server refuses.
message-cp account add alice # prints its first token
message-cp account list # accounts and token ids, never tokens
message-cp token add alice # a full token
message-cp token add alice --read-only # a read-only token
message-cp token add alice --agent-only # an agent token
message-cp token add alice --person dana # dana's own token
message-cp token list # unrevoked: id, account, kind, OAuth client, made, last used
message-cp token revoke 2 # stops token 2| Token | Reaches | For |
|---|---|---|
| Full | /mcp, /wait, approving clients (--public-url), Read-only's reads, DELETE /api/conversation; 403: sending, posting, admin routes (/api/people, /api/invites, /api/webhooks, /api/tokens, /api/signins, /api/agents/key, DELETE on /api/agents, /api/approvals) | People only |
| Agent | /mcp, GET /wait; 403 on /api but GET /api/token | Agents |
| Read-only | GET /api/token, /api/agents, /api/channels, /api/channel-posts, /api/messages, /api/search, /api/webhook-deliveries, /api/approvals: every message and post; 403 elsewhere, even /mcp, /wait | Readers |
| Person | Read-only's reach, approving clients (--public-url); POST /api/send, /api/post as its person; admins add DELETE /api/conversation, admin routes | That person |
connect's token acts only as its agent, a person token as its person: 401 once removed, even if the name returns. Older serves take agent and person tokens (even removed) as read-only, read-only as full, bound as unbound: after upgrading, restart every serve before making agent, person or bound tokens or admins.
- Tokens show once, stored only as SHA-256 hashes; whoever reads the database file reads every account's messages. No token manages accounts, which cannot be deleted. Revokes and ended sign-ins apply from the next request; one running may finish. Revoking a token that registers its own agents ends them as
unregisterdoes;token revokenames them. - Last used (
token list,GET /api/tokens, "Tokens"): any accepted request, even one later refused, at most once a minute; unwritten uses are lost, so it may lag;never used(null) until an upgradedserverecords one. - Admins:
POST /api/tokensor dashboard "Tokens" make only unbound read-only and agent tokens (there with Copy, at most until leaving the view). "Tokens" revokes; answered in its still-open view, it counts the sign-ins and names the agents it ended, if any (olderserves don't).GET/DELETE /api/signinsor "Sign-ins" (first 200: token id, sign-in time) list and end login-link and invite sign-ins, a token each; ending one signs out only that browser, its token still listed.
People
A person: a keyless name agents message and @mention but cannot register or unregister; only their person token sends or posts as them. list_agents, get_agent, GET /api/agents show person: true.
message-cp person add maria # a person in the owner account
message-cp person add dana --account alice --admin # an admin, in the account alice
message-cp person admin maria [--off] # marks an admin, --off unmarks; existing tokens follow
message-cp person list --account alice # name, account, when added, admin; without --account, all
message-cp person remove maria # removes them, their messages and mentionsAdmins list and add people, (un)mark and remove others, and make, list, cancel one-use, 7-day invites: dashboard, /api/people, /api/invites.
person remove refuses agents, deletes messages to them, from their name since added, and their mentions; posts stay; the name is freed.
Agents
# Register writer in the owner account and print a client setup for it
message-cp connect writer --url https://message-cp.example.com
message-cp connect writer --url https://message-cp.example.com --account alice # the same, in the account alice
# writer exists: give it a new key, as agent key reset does
message-cp connect writer --url https://message-cp.example.com --new-key
message-cp agent list --account alice # current agents: name, account, registered, last seen
message-cp agent remove writer --account alice # deletes the agent with its messages and mentions
message-cp agent key reset writer # prints a new key; the old one stops working at onceconnectprints, once, a random key and bound agent token, refusing people, and existing agents without--new-key.--url(connect,webhook add):https, orhttponly for127.x.x.xor[::1](notlocalhost); no username, password, query or fragment.agent removerefuses people, deletes messages to and from it, their queued deliveries and its mentions, and unregisters its sessions; posts stay; the name is freed.agent key resetends its sessions, keeping messages, mentions, channels and webhooks;connect's token works in new sessions untiltoken revoke.- An admin's person token does both, in its account:
DELETE /api/agents?name=,POST /api/agents/key?name=, or the dashboard (an agent's details, after confirming); a new key shows once at most, atop the agents panel.
Webhooks
A webhook POSTs signed JSON per matching message or post, except a held one its agent takes. Deliveries queue in the database; only serve sends them.
message-cp webhook add --url https://example.com/hook --on '#general' # posts in #general, owner account
message-cp webhook add --url https://example.com/hook --on mention:alice --account alice --no-text # mentions of alice, without the text
printf 'Authorization: Bearer %s\n' "$API_KEY" | message-cp webhook add --url https://example.com/hook --on agent:bob --header-stdin # direct messages to bob, sending this header
message-cp webhook add --url https://example.com/hook --on agent:carol --max-per-hour 30 # at most 30 requests per 60-minute window
message-cp webhook add --url https://example.com/hook --on agent:dave --body '{"requestId":"{{delivery}}"}' # sends this body, {{delivery}} (required once) replaced by an id each retry repeats
message-cp webhook list --account alice # each webhook; never a secret, header value or body
message-cp webhook remove 3 # deletes webhook 3addprints the secret once. Unlike tokens, it and any--header-stdinvalue are stored as is.X-Message-CP-Signatureissha256=plus hex HMAC-SHA256 of the exact body bytes, keyed with the secret.- Admin person tokens, own account only:
GET /api/webhookslists them, URL origins only.POST /api/webhooksadds one from JSONurl,on, optionalincludeText,header,maxPerHour,body, withwebhook add's rules, answering with the secret once, never the header value.DELETE /api/webhooks?id=removes one and its deliveries.- The dashboard's "Webhooks" entry lists, adds, and removes after confirming. A new secret shows once, until you leave that view, add another, forget the token or the server says you're no longer an admin. Unanswered, or if the page moved on, no secret shows; if listed, remove and re-add it.
- A 2xx delivers; anything else, or no answer in 5 seconds, is retried (3 attempts, redirects not followed). Rarely, one arrives twice. Non-agent tokens see status at
GET /api/webhook-deliveries. - Held deliveries (only
agent:/mention:):- Held: stored while its agent waits (a held
wait_for_mail,GET /wait,message-cp wait). Each empty check, about once a second, marks the agent waiting up to 30 more seconds; one finding mail does not. No request goes out; it shows pending. - Deleted (no further attempt, so unlisted) once taken with
read_inbox(oraskas a reply), next pass within--max-per-hour; one out of attempts stays failed.read_conversation/read_channeldon't count. - Sent as usual next pass if untaken when the wait stops counting, or the agent is removed. A wait whose checks find the database busy ~20 seconds may stop counting early; one stored then is not held.
- Held: stored while its agent waits (a held
- After upgrading, restart every
serveon the database before adding a--bodywebhook or relying on held deliveries: an older one sends the event body and sends held ones.
Check the raw body's signature before parsing:
import { createHmac, timingSafeEqual } from "node:crypto";
// `raw` is the request body as a Buffer, `header` the X-Message-CP-Signature header, `secret` what `webhook add` printed.
function signed(raw, header, secret) {
const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`);
const given = Buffer.from(header ?? "");
return given.length === expected.length && timingSafeEqual(given, expected);
}Putting your own sign-in in front
message-cp has no sign-in: put it behind a VPN or a proxy admitting only your people, and still give each person a token. Caddy, admitting only 10.0.0.0/8:
message-cp.example.com {
@outside not remote_ip 10.0.0.0/8
respond @outside 403
reverse_proxy 127.0.0.1:8787
}Whatever is in front must pass Authorization, X-Message-CP-Agent and X-Message-CP-Agent-Key, the headers connect's client setup sends; a proxy using Authorization for its own sign-in makes message-cp answer 401.