A team chat server that speaks the Slack Web API, built on Cloudflare Workers, D1, R2 and Durable Objects. API and CLI first; the app, for the web and the desktop, is one more client of the same API.
Because the API matches Slack's, existing Slack clients, bots and SDKs work
against Campflare after a base-URL change. The official @slack/web-api and
@slack/socket-mode packages run unmodified in the conformance suite, and
opentui-slack's Slack client passes its own
calls against it (verified 2026-10-11).
apps/server the Worker: Hono, D1 schema, TeamHub Durable Object
packages/client @campflare/client: Web API, Socket Mode and RTM client (no deps)
apps/cli the `campflare` command line
packages/core @campflare/core: app state kept live over RTM, mrkdwn parsing
apps/web the app: React, shadcn/ui on Base UI
apps/desktop the app in a Tauri shell for macOS, Windows and Linux
tests/conformance the official Slack SDKs against `wrangler dev`
nix develop # optional: the pinned toolchain (Node 24, pnpm, bun, sqlite3, jq)
pnpm ready --fix # install, secrets, builds, local migrations: whatever's missing
pnpm dev:all # wrangler dev on :8787 and a seeded workspacepnpm dev:all seeds a workspace on its first run (you are @alex; also
vincent, minjun and ada, with channels, a thread, a bot and a greeting in
seven languages in #world), and writes the tokens to
apps/server/.wrangler/campflare-dev.env on every run. --port 0 picks a free
port and --state <dir> a separate state, so parallel worktrees each run their
own stack; --fresh starts over and --no-seed leaves the server empty.
pnpm otui # opentui-slack against it (from ~/Projects/solcreek/opentui-slack)
set -a; . apps/server/.wrangler/campflare-dev.env; set +a
node apps/cli/dist/index.mjs send '#dev' "hello" # the CLI, as @alex
CAMPFLARE_TOKEN=$CAMPFLARE_BOT_TOKEN node apps/cli/dist/index.mjs send '#dev' "beep" # as the seeded app's botThe app runs against the same server and signs in by itself in development
(?as=vincent in the URL to be someone else):
pnpm web # http://localhost:5173
pnpm dev:all --port 0 --web # or server and app together, on free ports
pnpm shot '#/<channel id>' # screenshot of that app, its console errors and outline
pnpm desktop # the same app in a desktop window (needs Rust)
pnpm --filter @campflare/desktop bundle # installers for this platform
pnpm e2e # browser tests against a fresh stack of their ownwrangler dev runs everything locally in workerd: D1 is SQLite (with FTS5),
R2 is files on disk, and Durable Objects and their WebSockets behave as they do
in production. State lives in .wrangler/state.
cd apps/server
wrangler d1 create campflare # put the id in wrangler.jsonc
wrangler r2 bucket create campflare-files
wrangler secret put SIGNING_KEY # any long random string
wrangler secret put BOOTSTRAP_SECRET # lets campflare.bootstrap create workspaces
pnpm db:migrate:remote
pnpm deployDelete BOOTSTRAP_SECRET once your workspaces exist, to close sign-up.
The Worker doesn't serve the app (apps/web); host its build anywhere
(CAMPFLARE_WEB_ORIGIN=https://app.example.com pnpm --filter @campflare/web build)
and tell the Worker where it is, so the permalinks chat.getPermalink hands out
(/archives/<channel>/p<ts>) open the message there:
wrangler secret put APP_URL # e.g. https://app.example.com/ (a plain var works too)Push notifications want a VAPID key pair (pnpm vapid-keys prints one;
docs/push.md); without it, push is off:
wrangler secret put VAPID_PUBLIC_KEY # from pnpm vapid-keys
wrangler secret put VAPID_PRIVATE_KEY
wrangler secret put VAPID_SUBJECT # mailto:ops@example.com, for push services to reach youSubscriptions are accepted only at the browsers' push services (FCM, Mozilla,
Apple, Windows); a self-hosted one goes in PUSH_SERVICE_HOSTS (comma-separated
hosts, *.example.com for subdomains).
Without APP_URL, a permalink answers a page naming the app route to open, with a
link that opens the desktop app. With OFFER_DESKTOP_APP=true, a permalink
answers a page offering both, the desktop app and the browser, instead of the
redirect; nothing opens until the person picks one.
Point the client's API base at https://<your-host>/api/:
import { WebClient } from "@slack/web-api"
import { SocketModeClient } from "@slack/socket-mode"
const web = new WebClient(token, { slackApiUrl: "https://chat.example.com/api/" })
const socket = new SocketModeClient({ appToken, clientOptions: { slackApiUrl: "https://chat.example.com/api/" } })Tokens keep Slack's prefixes, so clients that check them keep working:
xoxp- (a person), xoxb- (an app's bot), xapp- (an app, for Socket Mode).
Get them with campflare bootstrap, campflare admin add-user,
campflare admin add-app and campflare token create.
https://<host>/api/<method>, by GET or POST (query string, form,
multipart or JSON), with Authorization: Bearer <token> or a token
argument. Answers are HTTP 200 with { "ok": true, … } or
{ "ok": false, "error": "<code>" }, using Slack's error codes. Lists page with
cursor and response_metadata.next_cursor.
| Area | Methods |
|---|---|
| auth | api.test, auth.test, auth.revoke |
| conversations | list, info, history, replies, create, join, leave, invite, kick, members, open, close, mark, rename, setTopic, setPurpose, archive, unarchive; users.conversations |
| chat | postMessage, update, delete, getPermalink, meMessage, postEphemeral |
| scheduled messages | chat.scheduleMessage, chat.scheduledMessages.list, chat.deleteScheduledMessage |
| reminders | add, info, list, complete, delete; DMed by the built-in Campflare bot |
| streaming | chat.startStream, appendStream, stopStream (markdown, blocks, task_update/plan_update as task_card/plan blocks); a person stops one with campflare.chat.stopStream; assistant.threads.setStatus |
| reactions | add, remove, get |
| pins/bookmarks/stars | pins.add, remove, list (messages; pinned_to in history, pin_count); bookmarks.add, edit, remove, list (http(s) links); stars.add, remove, list (saved messages, files and conversations; is_starred in history) |
| files | getUploadURLExternal, completeUploadExternal, info, list, delete; url_private downloads with range support |
| search | search.messages: words, "phrases", -exclusions, in:, from:, before:, after:, on:, during:, has:link/reaction/file, is:thread/dm |
| users | info, list, lookupByEmail, getPresence, setPresence, profile.get, profile.set (with status_expiration, announced when it expires), setPhoto, deletePhoto, campflare.users.setTimezone; user_change on every change |
| usergroups | create, list, update, disable, enable, users.list, users.update; subteam_* events; mentioned as <!subteam^S…|@handle> |
activity (campflare.*) |
activity.list, activity.mark (the inbox: mentions, DMs, thread replies, reactions); prefs.get, prefs.set (notification levels); push.vapidKey, push.subscribe, push.unsubscribe, push.list, push.test (Web Push) |
| dnd | info, teamInfo, setSnooze, endSnooze, endDnd; DND hours with campflare.dnd.setSchedule |
| realtime | apps.connections.open (Socket Mode), rtm.connect; the Events API over HTTP (signed event_callback POSTs to a Request URL) |
| interactivity | block_actions for buttons, icon buttons, feedback buttons, select and overflow menus (ephemeral messages' too), over Socket Mode or signed to an Interactivity Request URL; response_url; clicks by campflare.blocks.action |
| views (modals) | views.open, views.push, views.update with the trigger_id of an interaction; view_submission (with response_action) and view_closed; the person's side: campflare.views.list, submit, close |
| emoji | emoji.list (with include_categories), admin.emoji.add (from a URL), addAlias, rename, remove, list; uploads by campflare.emoji.add; emoji_changed |
| other | team.info, bots.info, apps.list |
admin (campflare.*) |
bootstrap, methods.list, users.create, users.deactivate, users.setRole, tokens.create, tokens.list, tokens.revoke, apps.create, apps.info, apps.setEventSubscriptions, apps.setInteractivity, apps.rotateSigningSecret |
| invitations | campflare.invites.create, list, revoke, accept; campflare.users.createPasswordReset, resetPassword, requestPasswordReset |
import (campflare.*) |
import.start, users, conversations, messages, files, finish, status, list, undo: a Slack export, in chunks |
The campflare.* methods cover what Slack does in its admin UI and OAuth
flow. They're named so they can't collide with Slack's.
chat.scheduleMessage keeps a message (up to 120 days ahead) and posts it at
post_at exactly as chat.postMessage would, as the person or bot that
scheduled it, with the same events and app_mention. reminders.add takes a
Unix time, seconds from now, or a phrase (in 15 minutes, tomorrow at 9am,
friday) in the person's time zone; at the time the workspace's built-in
Campflare bot DMs them "Reminder: …". A Durable Object per workspace
(Scheduler) keeps one alarm at the earliest due time; each item is claimed in
D1 before it is posted and finished in the post's own batch, so it posts once
even if the alarm runs twice. A message whose poster has since left the
channel is dropped, and the bot tells them. Recurring reminders aren't
supported yet. See docs/scheduling.md.
A token can do only what its scopes allow, checked as Slack checks them:
chat.postMessage wants chat:write, conversations.history on a private
channel wants groups:history, and a call without one answers
{ "ok": false, "error": "missing_scope", "needed": …, "provided": … }.
campflare.methods.list (no token needed) says what each method takes. A
person's own sign-in holds every scope; for an agent, mint a narrower one that
expires:
campflare token create --preset read --scopes chat:write --expires 8h --label triage-botPresets are read, write (read, post, react, upload) and full. A token
never gets a scope the token minting it lacks. Roles are Slack's: owners
manage roles (campflare.users.setRole), admins manage members, and acting on
someone takes a role above theirs. See
docs/scopes-and-roles.md.
An admin makes an invitation link, to one email or open to a number of people, and hands it over (no email is sent unless a sender is configured):
campflare admin invite --email ana@example.com --channel '#dev' # prints https://<host>/join/<code>
campflare admin invite --uses 20 --expires 3d # an open link for a team
campflare admin reset-password @ana # a single-use https://<host>/reset/<code>The person opens it and picks a handle and a password (a script-free page that
signs them in and sends them to the app), or accepts from a terminal with
campflare join <link> <handle>; agents call campflare.invites.accept. The
workspace hears team_join. Codes are stored only as hashes, links expire, and
using them is rate limited. See docs/invites.md.
An admin brings a Slack workspace export (the zip from Slack's export page) with its people, channels, private channels, DMs, threads, reactions, pins and edits:
campflare import slack export.zip --dry-run # who matches by email, which names are taken; writes nothing
campflare import slack export.zip # progress on stderr, a summary on stdout; re-run to continue
campflare import slack export.zip --files download --slack-token xoxp-… # bring the files too
campflare import undo J… # take it all back (within 7 days of finishing)People match by email; the rest become deactivated placeholders (add or invite
people first to have their history under their own accounts). A channel whose
name is taken becomes name-imported unless --merge-into-existing. The CLI
reads the zip here and calls campflare.import.* in chunks, which write
straight to D1: an import sends no events, activity or notifications. Every
row carries its job, so campflare.import.undo can remove it. See
docs/import.md.
Search uses an FTS5 trigram index that ignores case and diacritics. It matches
substrings in any script: words in Chinese, Japanese and Korean, which aren't
separated by spaces, as well as cafe for Café and munchen for München.
Terms shorter than three characters fall back to a LIKE scan.
users.setPhoto takes a PNG, JPEG, WebP or still GIF (optionally cropped
with crop_x, crop_y, crop_w); image_24 … image_1024 are squares
made from it by the Images binding (the original without it), at URLs that
change with every photo, so clients refetch. Like the drawn initials people
have without a photo, they need no token. A status past its
status_expiration reads as none. User groups are Slack's: a @handle (in
one namespace with people's names), members and default channels; anyone in
the workspace may manage them, as on Slack by default, with a token holding
usergroups:write (not in the write preset).
A workspace's own :name:: an image (PNG, GIF, JPEG or WebP, up to 128 KB,
scaled down to 128 px by the Images binding when bigger) or an alias of
another emoji, custom or standard. emoji.list answers Slack's map (name →
image URL, or alias:<name>); the images need no token, like Slack's. Slack's
admin.emoji.* (Enterprise there) work in any workspace: admin.emoji.add
fetches its url (https to a public address only, no redirects), and
campflare.emoji.add takes the file itself as a multipart image. Names follow
Slack's rules (lowercase letters, digits, - and _, up to 100) and can't take
a standard emoji's. Anyone may add, with a token holding emoji:write (in
full, not write); the creator or an admin may rename or remove one, and
removing an image removes its aliases. Each change is emoji_changed to the
workspace, and the app draws custom emoji in messages and reactions.
What needs a person's attention is recorded as it happens, one item per person:
a message that mentions them (<@U…>, or <!here>/<!channel>/<!everyone>
in a conversation they're in), a new message in their DMs, a reply in a thread
they wrote or replied in, a reaction to their message. campflare.activity.list
pages them newest first with the message and unread counts by kind
(unread_only, types); campflare.activity.mark marks some (ids), all up
to one (up_to) or all read. Items of a conversation the person left are
hidden, and a deleted message takes its items with it. Each item says whether
it should notify, from the person's level for that conversation
(campflare.prefs.set: all, mentions or none, and a default), their
snooze and their DND hours, so a push sender or a client just follows it; items are recorded
whatever the level. Clients learn of changes over RTM (activity_added,
activity_marked, activity_removed, prefs_changed). See
docs/activity.md.
campflare inbox --unread # what needs you; --follow keeps printing new items
campflare inbox read --all
campflare notify '#random' mentions # or none, all, default; `campflare notify` shows themItems that notify are pushed to people's browsers and installed web apps with
standard Web Push (VAPID and aes128gcm on WebCrypto, in the Worker), from the
workspace's hub after the response, so posting waits for none of it. The app's
workspace menu has Turn on notifications; the app is installable (a web app
manifest), which iOS 16.4+ requires for push. A notification says who and where,
the message as plain text, and opens the message when clicked. Nothing is pushed
to a person whose app window is focused (the app says so over RTM), at most 30
pushes per person per minute, and a push service's 404/410 forgets the device;
429 and 5xx are retried. campflare.push.subscribe takes a browser's
PushSubscription; the list never shows endpoints. See docs/push.md.
campflare push list # your devices; push remove <id>
campflare push test # a test notification to each of them, nowA person sets their time zone with campflare.users.setTimezone (an IANA name;
users carry Slack's tz, tz_label and tz_offset, DST included), and
reminders and DND hours are read in it. campflare.dnd.setSchedule sets DND
hours (22:00 to 08:00, on some days), which dnd.info and dnd.teamInfo
report as Slack's next_dnd_start_ts/next_dnd_end_ts and which keep activity
items from notifying; dnd.endDnd skips the window in progress. A status set
with status_expiration is cleared when it expires and the workspace gets a
user_change. The workspace's Scheduler does both: it clears due statuses and
moves ended DND windows on. See docs/dnd.md.
campflare profile --tz Europe/Paris # or --tz local
campflare dnd hours 22:00-08:00 --days mon-fri
campflare dnd snooze 30m # dnd end, dnd off; `campflare dnd` shows themEach workspace has one TeamHub Durable Object that holds its WebSockets,
using the hibernation API, so idle connections cost nothing. After a write, the
Worker calls the hub over RPC (after the response, via waitUntil), and the
hub sends the event to the connections whose user can see the conversation.
- Socket Mode (
apps.connections.open,xapp-): Slack'sevents_apienvelopes, acknowledged byenvelope_id. As in Slack, an app with several connections gets each event on one of them, not all. An app sees what its installer or its bot user can see. - RTM (
rtm.connect,xoxp-/xoxb-): the event objects themselves, plus client-state events such aschannel_marked. This is the stream for a Campflare UI. A live RTM link counts as active presence.
Measured on local wrangler dev: post → Socket Mode event, p50 6.6 ms, p95
9.5 ms (20 round trips, 2026-10-11; the conformance suite prints this on every
run). WebSocket URLs carry a signed, 60-second, single-use ticket.
An envelope that isn't acknowledged within 3 seconds is sent again, up to
three times, on the app's next link, with retry_attempt counting up and
retry_reason: "timeout"; its event_id stays the same.
Apps also get app_mention alongside message when a new message (a post,
a thread reply, a file share; not an edit, not the bot's own) mentions their
bot user in a conversation the bot is in.
An app without a live Socket Mode link can have its events POSTed instead, as Slack's Events API does, so apps built for Request URLs (Bolt's HTTP receiver, frameworks without a Socket Mode client) work unchanged:
campflare admin add-app "Helper" # prints the app's signing secret too
campflare admin app A0123 --request-url https://helper.example/slack/events --events app_mention,message.channelscampflare.apps.setEventSubscriptions (app, request_url, events) sends
Slack's url_verification challenge first and saves the URL only if it is
echoed; an empty request_url stops HTTP delivery. events limits what the
app receives (over Socket Mode too): event types, with message.channels,
message.groups, message.im and message.mpim for messages by kind of
conversation; empty means every event the app can see. campflare.apps.info
shows the settings and the signing secret. Request URLs are https, or http on
localhost for an app in development.
Each event is an event_callback (event_id, event_time, team_id,
api_app_id, authorizations) signed as Slack signs it: X-Slack-Signature: v0= HMAC-SHA256 of v0:<timestamp>:<body> under the signing secret, with
X-Slack-Request-Timestamp. Anything but a 2xx within 3 seconds is retried
after 1 s, 1 min and 5 min, with X-Slack-Retry-Num and
X-Slack-Retry-Reason (http_error, http_timeout, connection_failed);
an answer with X-Slack-No-Retry: 1 stops them. An app with a live Socket
Mode link gets its events there instead, as on Slack.
A Request URL that keeps failing is turned off, as Slack does: once more than
95% of the attempts in the last 60 minutes failed (retries count; at least 20
attempts, so a quiet app isn't turned off by a blip), Campflare stops sending
and retrying, records when and why, and the app's bot DMs its installer.
Setting the Request URL again (it is checked with url_verification again)
turns delivery back on; clearing it does too. Interactivity POSTs don't count.
campflare.apps.info reports how delivery is going, for people and agents
alike: event_subscriptions.delivery has the last attempt (at, ok,
status, reason), the hour's attempts and failures, and the events
pending a retry; event_subscriptions.disabled is {at, reason} while
delivery is off. campflare admin app A0123 prints the same.
To replace a signing secret (campflare.apps.rotateSigningSecret, by the
installer or an admin):
campflare admin app A0123 --rotate-secretThe new secret signs everything from then on, retries of earlier events and clicks included. There is no window where both secrets sign, so update the app's secret right away; until then it rejects every request.
Buttons and menus in an app's messages work as on Slack. A person clicks an
actions block's button or icon_button, a context_actions block's
feedback_buttons (👍/👎), or chooses in a static_select, users_select,
conversations_select, channels_select or overflow menu (in an actions
block, or a section's accessory), in the app or with
campflare click <#channel> <ts> [action_id] (without an action_id it lists
what's clickable and how: --option <value>, --user <@person>,
--conversation <#channel>). Under both is campflare.blocks.action
(channel, ts, action_id, block_id, and value: a feedback button's,
an option's, or the id chosen), Campflare's own method, since Slack's clients
use an internal one. As on Slack, anyone who can read the conversation can
click, public channels included. The action is described from the stored
blocks (selected_option, selected_user, selected_conversation,
selected_channel), and a select's choice is in state.values too. An app's
ephemeral messages with blocks are kept by the server for a day, so their
buttons click: the payload says is_ephemeral, and its response_url
replaces or deletes the message for the clicker alone.
The app whose bot posted the message gets block_actions in Slack's shape
(user, team, channel, container, message, actions[],
response_url, trigger_id): an interactive envelope over Socket Mode when
it has a link, else a signed form POST (payload=<json>) to its
Interactivity Request URL:
campflare admin app A0123 --interactivity-url https://helper.example/slack/events(campflare.apps.setInteractivity, app, request_url; not challenged, as
on Slack). Clicks aren't retried: an app that doesn't ack, or answer at all,
within 3 seconds gives the clicker app_unreachable; a URL answering non-2xx
(or, for a submission, not JSON) gives dispatch_failed. The response_url takes
the app's answer for 30 minutes, 5 times: replace_original,
delete_original, response_type: "in_channel", or by default a message only
the clicker sees (ephemeral). Posted as the app's bot, in the clicked message's
thread unless thread_ts says otherwise. A replacement isn't marked
"(edited)", as on Slack.
The trigger_id of a click (or of a submission) opens a modal for the person
who clicked: views.open with the app's bot token, once, within 3 seconds
(expired_trigger_id, exchanged_trigger_id). views.push stacks another on
it (3 at most), and views.update changes one by view_id or external_id,
with hash to guard against a newer change (hash_conflict). Inputs:
plain_text_input, email_text_input, url_text_input, number_input,
static_select, users_select, conversations_select, channels_select
(and their multi-selects), checkboxes, radio_buttons, datepicker,
timepicker. The app shows the top view as a dialog. With the CLI:
campflare view list # your open views, their inputs, how to fill each
campflare view submit V0123 --value note.text="Ship it" --value env.pick=prod --value who.pick=@vincent
campflare view close V0123Under them are campflare.views.list, campflare.views.submit (view_id,
values: { block_id: { action_id: value } }, an input left out keeping its
initial value) and campflare.views.close. A submission with required inputs
left empty answers errors without bothering the app, as Slack's client does.
Else the app gets view_submission with the view's state.values in Slack's
shape, and its answer (the Socket Mode ack's payload, or the Request URL's JSON
body) decides: response_action errors (shown under the inputs), update,
push, clear, or nothing, which closes the view. Closing one sends
view_closed when it has notify_on_close. A click in a modal is
block_actions with container.type: "view" and the view's state so far.
An app registers slash commands (Slack's manifest slash_commands), with its
installer's user token or its own bot token (commands scope):
campflare admin app A0123 --command '/deploy:Deploy a branch:[branch]' --command-url https://ci.example/commands(campflare.apps.setCommands, app, commands: [{ command, description, usage_hint, url }]; a name belongs to one app in the workspace,
command_taken). People run them from the app's composer, which offers the
matching commands as the name is typed, or with campflare command '#ops' /deploy staging (campflare.commands.invoke; campflare.commands.list
lists them). The app gets them as Slack sends them: a slash_commands Socket
Mode envelope, whose ack payload is the answer, else a signed form POST to the
command's URL with Slack's fields (command, text, user_id,
channel_id, response_url, trigger_id…), whose body is. The answer is
shown to the person alone, or posted in the conversation with
response_type: "in_channel"; the response_url works as a click's (30
minutes, 5 uses) and the trigger_id opens a modal. Errors:
command_not_found, app_unreachable, dispatch_failed.
An incoming webhook posts in one conversation as an app's bot:
URL=$(campflare admin webhook create A0123 '#alerts' --label CI)
curl -X POST -H 'content-type: application/json' -d '{"text":"Build passed"}' "$URL"
campflare admin webhooks A0123 # admin webhook revoke H0123 stops onePOST <origin>/services/<secret> takes Slack's body (text, blocks,
attachments, thread_ts; JSON, or a form's payload) and answers Slack's
plain text: ok, or invalid_payload, no_text, no_service,
channel_not_found, channel_is_archived, action_prohibited with Slack's
statuses. Each webhook takes a burst of 10, then one a second (429 with
Retry-After). Official clients such as @slack/webhook work unchanged. See
docs/commands-webhooks.md.
The hub also keeps every event for a week, numbered in order.
campflare.events.poll returns the events the token's user can see after a
cursor, long-polling up to wait seconds (at most 30) when there are none
yet. Without a cursor it answers the current one, to start from. A cursor whose
next event has been dropped answers cursor_expired, so a client knows it
missed something. This is live events for clients that can't hold a WebSocket
open: agents, scripts, scheduled jobs. follow() in @campflare/client and
campflare events --follow wrap it.
A conversation can have a call: audio, video for several people (simulcast,
with the layer chosen per viewer), and optionally a live transcript with a
summary at the end, posted in a thread by a Huddle Scribe bot. Media goes
through Cloudflare's Realtime SFU; signalling is campflare.huddles.* (join,
subscribe, answer, layer, publish, unpublish, leave, info, ice_servers,
transcribe) and RTM events (huddle_joined, huddle_left, huddle_tracks, huddle_transcription).
Speech is transcribed by Whisper and summarised by Llama 3.3 on Workers AI.
Huddles are off until the Worker has a Realtime SFU app (REALTIME_*;
transcription also needs CF_ACCOUNT_ID and WORKERS_AI_TOKEN); see
apps/server/.dev.vars.example. There's no UI yet: /huddle is a test page.
How it works, how it's tested and what it measured: docs/huddles.md.
https://<host>/mcp is a remote MCP server (Streamable HTTP), built on the
official TypeScript SDK v2. It implements the 2026-07-28 specification
(server/discover, no handshake) and still serves 2025-era clients through the
SDK's stateless fallback. Agents use it to work in the workspace as a person
or a bot:
| Tool | What it does |
|---|---|
read_messages, read_thread |
a conversation's recent messages, or a whole thread |
search_messages |
search, with the same syntax as search.messages |
send_message, add_reaction |
post (or reply in a thread), react |
schedule_message, set_reminder |
post later; remind yourself (times as people say them) |
list_scheduled, cancel_scheduled |
your scheduled messages; delete one before it posts |
list_pins, list_saved |
what's pinned in a conversation; what you saved for later |
list_conversations, list_people, whoami |
where you are and who's here |
list_groups |
user groups, their members, and how to mention each |
list_emoji |
the workspace's custom emoji and aliases |
get_activity, mark_activity_read |
what needs your attention (unread by default), and done with |
get_events |
the event log since a cursor, long-polling |
wait_for_reply |
block until someone else answers in a conversation or thread |
Conversations are named as people name them: #channel, @handle (your DM)
or an id. Tools run the same handlers as the Web API, so permissions, events
and error codes are the same; errors come back as tool errors the model can
read.
MCP clients sign in with OAuth 2.1, as the specification asks. Better Auth, at
/auth, is the authorization server:
- An unauthenticated call to
/mcpanswers401withWWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp". - That document names the authorization server; its metadata is at
/.well-known/oauth-authorization-server/auth. - The client identifies itself with a Client ID Metadata Document (an HTTPS
URL as its
client_id), the MCP 2026-07-28 way; dynamic client registration is off. PKCE (S256) is required. - The person signs in on
/loginand approves on/consent. Until there's a UI with passwords or passkeys, signing in takes a Campflare user token (campflare token create), which also says which workspace and person the client acts as. - The client gets a JWT access token bound to
/mcp(and a refresh token withoffline_access).
Agents configured by hand can skip OAuth and send a user or bot token:
claude mcp add --transport http campflare http://localhost:8787/mcp \
--header "Authorization: Bearer $CAMPFLARE_TOKEN"Client metadata documents are fetched over an SSRF-guarded transport: https
DNS names only, every DNS answer public (RFC 6890), no redirects. A Worker
can't pin the address it checked, so the gap between that lookup and the fetch
relies on Workers not reaching private networks; under local wrangler dev
that guarantee doesn't hold.
campflare login --url https://chat.example.com --token xoxp-… # or any Slack-compatible server
campflare login --url https://chat.example.com --oauth # Campflare: sign in in the browser
campflare send '#dev' "build is green"
campflare send @vincent "have a look" -f screenshot.png
make test 2>&1 | tail -5 | campflare send @me # text from stdin
campflare history '#dev' -n 50
campflare search 'deploy in:#dev from:@bob after:2026-10-01'
llm "summarise" | CAMPFLARE_TOKEN=xoxb-… campflare stream '#dev' --thread 1791990000.000100 --status "is thinking…"
# as a bot: one message that grows as stdin arrives
campflare stop '#dev' 1791990000.000200 # as a person: stop an app's reply mid-stream
campflare send '#dev' "standup in 5" --at 2026-10-16T08:55:00Z # schedule it (or --at 30m, 2h, epoch seconds)
campflare scheduled # unschedule '#dev' <id> deletes one
campflare remind "renew the certificate" tomorrow at 9am # reminders; reminder done <id>
campflare pin '#dev' 1791990000.000300 # pins '#dev' lists them; unpin takes it back
campflare bookmark add '#dev' https://ci.example.com --title CI --emoji :rocket: # bookmarks '#dev'
campflare save '#dev' 1791990000.000300 # saved lists them, newest first; unsave
campflare listen --json # RTM; --socket for Socket Mode
campflare events --cursor 120 --follow # the event log, resumable
campflare inbox --unread --type mention,dm # what needs you (Campflare)
campflare push test # a test notification to your devices
campflare command '#ops' /deploy staging # run an app's slash command; commands lists them
campflare api conversations.list limit=5 # any method, raw JSON
campflare profile --status 'En réunion' --emoji :calendar: --until 1h --photo me.png
campflare group create oncall --name 'On call' --members @ana,@kenji # then mention @oncall
campflare emoji add shipit shipit.gif # custom emoji; emoji alias, rename, remove
campflare join https://chat.example.com/join/<code> ana # accept an invitation, log in as ana
campflare import slack export.zip --dry-run # an admin bringing a Slack exportProfiles are kept in ~/.config/campflare/config.json (mode 0600).
CAMPFLARE_URL, CAMPFLARE_TOKEN and CAMPFLARE_APP_TOKEN override them.
Exit codes: 0 ok, 1 API error, 2 usage error. Run campflare help for the full
list.
pnpm ready # is this checkout ready? --fix repairs it; --json for scripts and agents
pnpm check # oxfmt --check, oxlint, tsc, and every test suite with coverage
pnpm test # just the tests
nix develop -c pnpm check # the same, on the pinned toolchain, on any machine with NixCI (GitHub Actions) runs pnpm check and pnpm e2e on every push and pull
request; the desktop installers and the desktop end-to-end tests run by hand
or on a v* tag (.github/workflows/desktop.yml).
The toolchain comes from flake.nix: Node at the major in .node-version,
pnpm, bun (for pnpm otui), sqlite3 (to look inside local D1 state) and jq.
wrangler and workerd are not in it: they come from the pnpm lockfile, whose
versions must match @cloudflare/vitest-pool-workers.
Claude Code sessions get the same setup without asking (.claude/settings.json):
the flake's toolchain on every command's PATH, a note at session start if
pnpm ready finds something wrong, each edited file formatted and linted on
the spot (lint errors go back to the agent), and format, lint and typecheck
(about 2 s) required to pass before the agent stops whenever code changed.
apps/server: vitest inside workerd (@cloudflare/vitest-pool-workers), against real local D1, R2 and Durable Objects. Coverage thresholds: 95% lines, statements and functions; 85% branches.packages/client: unit tests with a fake fetch and a fake WebSocket.apps/cliandtests/conformance: start a realwrangler devwith fresh state, then run CLI commands and the official Slack SDKs against it.
Tooling: TypeScript 7, Hono, oxlint, oxfmt, and tsdown (on rolldown) for the client and CLI bundles. The Worker is bundled by wrangler.
- Guests (multi- and single-channel), and filtering the live event feeds by a token's scopes (docs/scopes-and-roles.md).
- Passkeys, and an email provider behind the email seam (invitations and resets are links today; docs/invites.md).
- Periodic Socket Mode
disconnectrefreshes. - Rate limiting (HTTP 429 with
Retry-After). - Native notifications in the desktop app (its webview has no Push API), and closing a notification on other devices when an item is read (docs/push.md).
- Claiming an import's placeholder for a real account, and Enterprise Grid exports (docs/import.md).
- An admin setting restricting who may manage user groups or custom emoji. Pinning files (pin the message that shares one), file comments, bookmark folders and message or file bookmarks.
- Recurring reminders, and reminders for someone else (docs/scheduling.md).
- Sharding a very large workspace's hub across several Durable Objects.
- Huddles: expiring the seat of a browser that died without leaving, and
Slack's
calls.*API (see docs/huddles.md).