An inbox for opsophiles: one place for an ops team to work through every Slack, Microsoft Teams and Discord channel it is responsible for, with per-person and per-team read state.
Status: all four piles live. Signed Slack events are stored in R2 and queued for
normalization; Teams: a cron keeps one tenant-wide Graph subscription alive, and its
notifications and the messages they name are stored the same way; Discord: a Durable Object
polls the servers it reads every minute and stores their new messages, and every few minutes
the last day's edits, deletions and reactions (pile 1). A queue consumer
turns every raw object into one markdown file per message (pile 2), filed by the channel rules
in D1, and labels, re-threads and titles the threads with Workers AI. The inbox (pile 4) shows
them per team with read state, status piles, filters, replies, reactions, ✅ to resolve and
shared drafts, all as the person signed in; the admin pages manage teams, members, rules and
customers, and the knowledge-base editor the knowledge bases (pile 3). Thread agents draft
replies and fill the knowledge bases in the channels where they are switched on. Agents outside
the browser work the inbox through /mcp, and agents in the browser through WebMCP. See
docs/ARCHITECTURE.md for the design and the open issues for what
comes next.
One Cloudflare Worker, deployed as helix-opspile-prod and helix-opspile-stage. Production
answers on its custom domain (WORKER_DOMAINS, the PUBLIC_ORIGIN); its workers.dev address
stays on as a retired origin (RETIRED_ORIGINS): pages there redirect, and machine endpoints
still answer.
| Route | Purpose |
|---|---|
POST /slack/events |
Slack Events API request URL. Rejects requests without a valid signature (401). Answers url_verification; stores every event callback in pile 1 (without Slack's verification token) before acknowledging it, and answers 500 if that fails so Slack retries. |
POST /teams/notify |
Microsoft Graph change-notification URL. Echoes validationToken. Drops notifications whose clientState is not GRAPH_CLIENT_STATE, stores the rest in pile 1, answers 202, then fetches and stores the message. |
POST /teams/lifecycle |
Graph lifecycle-notification URL. Stores lifecycle notifications the same way, then renews (reauthorizationRequired) or recreates (subscriptionRemoved) the subscription. missed starts a catch-up of the last two hours. |
GET / |
Redirects to /inbox. |
GET /favicon.ico, /apple-touch-icon.png, /icon-192.png, /icon-512.png, /icon-maskable.png, /icon.svg |
The icons (src/icons.js, generated by scripts/icons.mjs from scripts/adobe-icon.svg). |
GET /manifest.webmanifest, /sw.js |
The installable app (src/pwa.js): web app manifest (start /inbox, standalone, shortcuts to the editor and admin pages) and a service worker that caches nothing and only answers a page load made offline. Public, outside Access. |
GET /inbox |
The inbox UI (one static page), behind Cloudflare Access. |
GET /inbox/admin |
Teams, members, rules and manual assignments; customers, partners and vendors (name, icon, type, manual assignments). One static page, for the admin team; team managers see their teams' members there (add, remove, promote, demote). |
/api/admin/* |
Its API, for the admin team (team managers: teams, team, people and members of their teams only): teams (PUT { slug, name?, selfManaged? }), team?slug=, members, people?q= (to add as members), members (POST adds, PUT { team, person, role } promotes or demotes, DELETE removes), rules, assign (POST { target, source, channelId, value, move? }: a team added to a channel the admin team or nobody holds gets it filed under it; added to a channel another team holds, it is shared with it instead, answered as shared, and nothing moves; move: true, or the admin team, moves it), share (DELETE { source, channelId, team }: the team stops following a channel shared with it), channels?q= (every word in the channel's name, Teams team name, org slug or name, or id; the answer says which matched), orgs (each with domain, its usual mail domain), org?slug= (GET, and POST/PUT/DELETE with JSON bodies), org-icons?slug=[&fresh=1] (icons for an org, ranked by Clef, src/favicons.js), favicon?domain= (Google's favicon of a domain, 404 when it has none), and classify (POST { limit? }: Clef classifies up to 25 of the admin team's channels now). Rule changes take dryRun: true and answer with the channels they would move; applied, they queue refile jobs. For the admin team, team?slug= also answers with suggestions: the admin team's channels where the team's members post, react or are members, and the ones Clef gives the team (see docs/ARCHITECTURE.md). |
GET /inbox/kb |
The knowledge-base editor (one static page), for the admin team, and team managers for their teams' knowledge bases: list the knowledge bases and their files, edit concepts and the agents' systemprompt.md (saved as you type), see the history. |
GET /api/kb, GET /api/kb/files?repo=, GET /api/kb/log?repo=, GET/PUT/DELETE /api/kb/file |
The editor's API, for the admin team (team managers: their teams' knowledge bases; the other members of a team: its knowledge base, and every GET /api/kb* of it, read-only; may on each knowledge base says whether you change it): knowledge bases, files, commits, and one file (?repo=&path=); PUT { repo, path, content, base?, message? } commits it as the signed-in person (base: the blob it was written from; without one the file must not exist yet), DELETE { repo, path, base } removes it. 409 with the current file when it changed meanwhile. |
GET/POST/PUT/DELETE /api/kb/mcp, POST /api/kb/mcp/check |
The agents' MCP servers of a knowledge base's team or customer (?repo=, or { repo, name, url, description?, auth?, headers?, oauth?, allow?, enabled? }; PUT/DELETE/check by id), for whoever may edit it. The members of a team GET its servers read-only (may: false), their URLs without user info, query or fragment, which may carry a credential. auth is headers (default) or oauth, with oauth: { clientId?, clientSecret?, scope? } for a client registered at the server. Header values, tokens and client secrets are never returned, only header names and whether and by whom an OAuth server is connected; adding or changing a server connects to it and lists its tools. The answer also lists the global servers (globals): whether each is set up, its tools, and its switches for the knowledge base's team and channels. |
POST /api/kb/mcp/oauth/start, GET /api/kb/mcp/oauth/callback, POST /api/kb/mcp/oauth/disconnect |
The OAuth sign-in of an agents' MCP server (auth: oauth), for whoever may edit it: start { id } answers with the URL of the server's sign-in (discovery, client registration and PKCE done); the server sends the browser back to callback?code&state, which only the person who started may finish, keeps the grant (sealed with TOKEN_KEY), lists the tools and returns to the editor; disconnect { id } revokes the grant where the server can, and forgets it. |
POST /teams/bot |
The messaging endpoint of the opspile Teams bot. The bot is notification-only and opspile reads Teams through Graph, so it answers every activity with 200 and keeps nothing. |
GET /api/kb/extraction?repo=, POST/DELETE /api/kb/extraction/rule, POST/DELETE /api/kb/extraction/tech, POST /api/kb/extraction/test |
A team's extraction rules and tech stack (src/extract.js), from its knowledge base (?repo=, or { repo, … }): GET the team's and the admin team's, which apply to every channel (the members of the team, read-only: may: false); rule { id?, name, kind, target, field, pattern, flags, value, transform, exclude, script, enabled } adds a rule or changes rule id, DELETE rule { id }; tech { tech, name, description, enabled } adds or changes a technology, DELETE tech { tech }; test { rule, text } what a rule finds, unsaved too. For the admin team and the team's managers. |
GET /api/kb/tools?repo=, PUT /api/kb/tools |
The agents' switchable tools (post_message, computer, extraction_rules, any_thread) of a knowledge base's team or customer: whether this deployment can run each, the team's switch and the channels' switches; PUT { tool, team, enabled } for a team, { tool, channel, enabled } for one channel (enabled: null: the channel follows its team again), for the admin team and the team's managers; any_thread only for the admin team, per channel, in internal channels. |
PUT /api/kb/mcp/global, POST /api/kb/mcp/global/check |
A global MCP server (browser, github) on or off: { server, team, enabled } for a team, { server, channel, enabled } for one channel (enabled: null: the channel follows its team again), for the admin team and the team's managers; check { server } connects to it and lists its tools again. |
POST /api/kb/github/manifest, GET /api/kb/github/created |
The GitHub App of the global github server, for the admin team: { org?, name? } answers with the manifest the editor posts to GitHub; GitHub sends the admin back to created?code&state, which keeps the app (its private key sealed with TOKEN_KEY) and goes on to its installation page. PUT /api/kb/github/installation { id, approved } allows an installation (agents read only allowed ones) or revokes it. |
GET /api/me, /api/inbox?team=, /api/inbox?view=personal, /api/thread?thread= |
The signed-in person, a team's threads with read state, unread mentions of the viewer (unreadMentions) and who each waits on (waiting_on: Clef's next actor, but never us when Adobe posted last), the personal view's threads (the conversations in the channels the viewer posted or was mentioned in, whichever team they are filed under, with the view's rooms), one thread's messages from pile 2. Both lists carry profiles: a profile card per author (Adobe, partner, customer or bot, title, mail domain, ops teams), keyed by platform account. A thread also carries mentioned: who the people its messages @-mention are (Adobe, partner, customer or bot), by name, so the page sets mentions of people outside Adobe apart, and can_post_as_bot: whether the viewer may send as the opspile app there (POST /api/post/app). GET /api/me also says which rooms the inbox listens to (rooms: the person's teams, and the teams the channels of their personal view are filed under). Behind Access; the Worker also verifies the Access JWT and ties the email to a person in D1. |
GET /api/search?team=&q=, /api/search?view=personal&q= |
Search over a team's inbox (or the personal view), on every platform at once (src/search.js): threads about q by meaning (the CONTENT vectors of their messages and of the threads as a whole), threads with a message that has every word of q (BM25 over the SEARCH items) and threads whose title, channel, customer or facts have its words, best match first. The entries come as /api/inbox shapes them, with hits in order: why each matched (title, place, fact, text, topic) and the message that matched best (snippet). |
GET /api/review/queue, GET /api/review/stats, POST /api/review |
Label review for members of the admin team: unreviewed labels (least confident first), Clef's agreement per question, and a correction for one question of one label. |
POST /api/post |
Reply in a thread as the signed-in person, through their own Slack or Microsoft account. 409 with connect when they have not connected it yet. Only staff, only in their teams. mentions (account ids) turn each @Name of those accounts into a real Slack (<@U…>) or Teams (<at>) mention, so the person is notified. Emoji :names: go out as the platform knows them (another platform's name becomes Slack's, a name nobody has the emoji Clef picks, see docs/ARCHITECTURE.md "Replying"); text in the answer is the reply as sent. With files, the body is multipart/form-data: the JSON as payload, each file as files (up to 10; src/attach.js). Slack uploads them (files.getUploadURLExternal, files.completeUploadExternal in the thread, the text as initial_comment); Teams puts images inline (hosted contents) and uploads other files to the channel's SharePoint folder, attached by reference. 409 with reconnect and files: true when the account must be connected again with file access first. With channel (source:tenant:channelId, as GET /api/channels lists it) instead of thread, the message starts a thread there, in the same way; the answer's thread is the inbox entry it will be, once it has come back through the platform's events (none when Slack uploaded files: Slack does not name the message). |
GET /api/channels |
Where the signed-in person may start a thread, latest activity first: the conversation channels of their teams, those shared with their teams, and those of their personal view (personal), with the teams whose inboxes have each. Not Discord, where the inbox cannot post as them, and no alert or ignored channels. |
POST /api/post/app |
Reply in a thread as the opspile app (the Slack app, the Teams bot, the Discord bot) instead of as the signed-in person, { thread, text, mentions?, draft? }: the same post as a thread agent's post_message, and only where that may post, in channels whose Post messages switch (team or channel, knowledge-base editor) is on and whose platform's bot is set up (409 otherwise). Only staff, only in their teams. mentions as for /api/post: a real Slack (<@U…>, in the markdown block too), Discord (<@id>, the only pings the post makes) or Teams mention (<at> with a Bot Framework mention entity; a Teams bot stays plain text, since Teams gives no bot what a bot writes). Emoji :names: as for /api/post. Files as for /api/post: on Slack uploaded with the bot token, on Discord in the message (files[n], 10 MB in all), on Teams only images (inline, PNG, JPEG or GIF up to 1 MB), since Teams lets bots send other files in personal chats only. |
POST /api/edit |
Edit a message one sent, { thread, id, text, mentions? } (thread is the message's platform thread, as for /api/react), the way it was sent (src/edit.js): one's own message as the signed-in person (Slack chat.update with their token; Teams PATCH on the channel message or reply, which needs ChannelMessage.ReadWrite, see TEAMS_EDITS; not on Discord, which lets only bots edit), or a message one posted as the opspile app (recorded in app_posts) as the app (Slack chat.update with the bot token, the Teams bot's update activity, Discord PATCH), where the app may still post. Mentions the message had (@Name) stay mentions. The edit comes back through the platform's events; /api/thread marks each message the viewer may edit (editable: 'user' | 'app'), /api/me whose tokens may (edit). 409 with connect or reconnect as for replies. Only staff, only in their teams. |
POST /api/react |
Add (or with remove, take off) a reaction on one message as the signed-in person: { thread, id, emoji }, where thread is the message's platform thread and emoji a picker name (thumbsup, white_check_mark, …) or a reaction as the thread shows it. Slack reactions.add/remove, Teams setReaction/unsetReaction; recorded in D1 at once, then again by the platform's own event. 409 with reconnect: 'slack' for Slack tokens from before reactions:write. Only staff, only in their teams. |
POST /api/resolve |
{ thread, undo? }: ✅ on the root message of the entry's latest platform thread, as the signed-in person (white_check_mark on Slack, ✅ on Teams), or with undo take their ✅ off every root of the entry. A staff ✅ on the root marks the thread resolved (see ARCHITECTURE, "Resolved"). |
GET, PUT, DELETE /api/draft |
The thread's shared draft (at most one per inbox entry), seen by everyone in the teams that follow the channel (the team it is filed under, and those it is shared with). PUT { thread, text, mentions?, base } saves it as the signed-in person, DELETE { thread, base? } discards it (a draft the thread's agent saved: the agent hears who discarded it); base is the updated_at of the draft the text was written from, and a draft that changed since answers 409 with the current one. A thread agent's draft has no picked mentions: each @Name in it that stands for exactly one account of the workspace or tenant becomes one when it saves (writtenMentions, also for its post_message and the MCP post tools). POST /api/post or /api/post/app with draft: <base> uses the draft up. Inbox entries carry draft (who, when, a preview), the thread its full draft. Only in their teams (admins can see any thread's draft). |
GET /api/experts?thread= |
The thread's expert finder: Adobe people worth pulling in who are not in it yet, best first, with their account id, reasons in words, response (median time to answer a mention, when there are enough) and member (whether they are in the channel; null when its members were never listed). A thread agent's suggestions come first, with suggested. Once Adobe staff wrote three messages in the thread, only those, unless the viewer was mentioned and has not posted since. list is the id the list was recorded under, for the viewer (not while viewing as someone else). See docs/ARCHITECTURE.md, "Expert finder". |
POST /api/experts/event |
What the page did with a list of GET /api/experts: { list, action, account? }, the action one of shown, open (its ▾ list), pick (of account, someone on the list), reject (account is not the right person: left out of your lists of the thread from then on) and dismiss (the ▾ list closed or left with nobody picked); 204. Only of your own lists (expert_events, src/expertevents.js). |
GET /api/experts/events?since=&limit= |
For the admin team: the expert finder's lists and their events, oldest first, 500 a page, next when there are more; npm run rank -- experts fetches them. |
GET /api/draft/source?thread=&url= |
How the inbox can show one of the sources of the thread's draft: { view: 'frame' }, { view: 'markdown', text } or { view: 'none', reason } (src/sources.js) |
GET /api/mentionable?thread=&q= |
Who can be mentioned in a reply: accounts in D1 for the thread's platform and workspace where q starts any word of the name, login, display name or mail, in any case; customers in the thread first, then the Adobe people whose messages are closest to the thread (CONTENT vectors, expert), then the rest of the thread, the channel's members, the best match, staff. An empty q lists the channel's members in the background when that is a week old. With pool, also pool: everything the suggestions are picked from (accounts, bots), which the inbox ranks itself (suggestMentions, src/mention-match.js) for what is typed next. |
GET /api/emoji?q= |
The composer's emoji suggestions for :q being typed: the candidates of src/shortcodes.js (the emoji most alike by name and CLDR keywords, then common ones), as Slack names, most likely first by Clef's probabilities, all of them. The inbox shows them after the names that have q in them, until its list is full. Empty when Clef fails or takes over 4 seconds; rankings are remembered per isolate and for a day in the data center's cache, and the browser may keep one for a day. |
GET /api/emoji/custom |
Slack's custom emoji for the inbox, from emoji.list: images (name → address), aliases (name → the standard emoji it stands for), complete (whether Slack said). |
GET /api/emoji/closest?names= |
For names with neither a character nor an image (comma-separated, at most three looked at): name → the Slack name of the emoji Clef picks, without those it picks none for. Empty until emoji.list answered. |
GET /api/auth/{slack,teams}/start, …/callback, DELETE /api/auth/{slack,teams} |
Connect or disconnect a person's Slack (user scopes chat:write, reactions:write) or Microsoft account (delegated ChannelMessage.Send, offline_access, User.Read). start?files=1 also asks for file uploads (Slack files:write; Microsoft Files.ReadWrite.All, for files other than inline images on Teams), which the inbox offers when someone first attaches a file. The account must be the person's own (same person in D1, or same email). Tokens are stored encrypted with TOKEN_KEY. |
GET /api/media?u= |
Images attached to messages (Slack files, Teams hosted contents), fetched with the platform's credentials; nothing else is fetched. |
GET /api/file?t=&u= |
A text file (CSV, log, JSON, HAR) a message of thread t links to, for the inbox's preview: fetched with the app's credentials as an agent's read_file does, and passed on only as text/plain, up to 10 MB (a HAR up to 150 MB). |
GET /api/file/stream?t=&u= |
A video or audio file a message of thread t links to, streamed with the browser's Range for the inbox's player (Slack's MP4 copy of a video where it made one). |
GET /api/file/poster?t=&u= |
The still Slack made of such a video, the player's poster. |
GET /api/people, header x-opspile-as: <person id> |
Admins: the people in the ops teams, and any API call as one of them (read-only: no reads, replies, reviews or connections). |
POST /api/read, GET /api/live?team= |
Mark a thread read; the team's live WebSocket (read events and new activity), served by the team's Durable Object (or one of the viewer's rooms, scoped to the channels of their personal view). A new message's thread event names the people it newly mentions (mentions) and the people whose thread it is (involved: they posted in it, were mentioned in it or put 👀 on it; only a person's new message, not an edit or a bot's), with by, author and at, when it was posted in the last hour, so their inboxes notify them. |
GET /api/settings, PUT /api/settings |
The signed-in person's settings (also in GET /api/me as settings): mentions (notify of messages that mention them; on by default), threads (notify of messages in threads they posted in, were mentioned in or put 👀 on; on by default), bells (views that notify of new messages: { view, sel, not }, one of their teams or ~personal with the filters picked and excluded by facet; at most 20), traces (show the thread agent's thinking under threads; on by default) and channelTraces ({ <channel id>: true | false }, where a channel differs from traces; at most 500). PUT changes what the body has; channelTraces changes the channels it names, null drops one. Kept in D1 (person_settings). |
POST /mcp |
An MCP server (Streamable HTTP, protocol 2026-07-28; 2025 clients via initialize), with its own OAuth (open client registration, consent behind Access). See "MCP server" below. |
/.well-known/oauth-*, /oauth/authorize, /oauth/register, /oauth/token, /oauth/revoke, GET /api/oauth/consent/…, POST /api/oauth/authorize |
OAuth for MCP clients. |
GET /api/agent?thread=, POST /api/agent/seed |
Whether a thread has an agent and what it is doing (its watermark, pending runs, and its latest turn: { state: working | done | failed, text, startedAt, endedAt }, which the inbox shows under the thread's last message); share knowledge with an agent: { thread, text } for a thread's agent, { team, org, text } for the knowledge-base agent of a team and customer. kb (team or org, on either form) chooses the knowledge base, and the agent cannot write the other; { team, kb: "team", text } needs no customer. Without it the agent decides. Only in their teams. See "Thread agents". |
GET, POST /diag/agent?name= |
One agent by name (an inbox entry key, kb:<team>:<org>, or kb:<team>: for no customer), guarded by x-opspile-diag: GET its status, POST {} delivers new messages, POST { text } seeds it. |
GET, POST /diag/dead |
The queue jobs that used up their retries, guarded by x-opspile-diag: GET lists them and counts them by kind; POST sends them out again, the oldest 100 or those of { "ids": […] } |
GET, POST /diag/discord |
The Discord poller, guarded by x-opspile-diag: GET says when it polls next and how the last poll went (an error, e.g. a missing permission, included); POST polls now, and reads the last day again for edits, deletions and reactions. |
GET /diag/teams |
Self-test of the Microsoft side, guarded by the x-opspile-diag header. Returns the token's granted roles and the number of visible teams; ?sub=1 also creates and immediately deletes a /teams/getAllMessages probe subscription; ?ensure=1 runs the cron's renewal now. Never returns a token. |
queue helix-opspile-normalize |
Also carries thread backfill requests, label and vector jobs, thread-agent pokes and re-filing after a rule change (see ARCHITECTURE). Pile 1 → pile 2: reads the raw object, looks up the channel and author (cached in D1), applies the rules, and writes channels/<org>/<team>/<source>-<channelId>/<rootId>/<messageId>.md. Five retries, then helix-opspile-normalize-dlq. |
cron */20 * * * * |
Creates missing team knowledge bases (pile 3), and imports or refreshes the Artifacts mirrors of the GitHub repositories agents may read. Checks the global MCP servers once a day and keeps the tools they list for the agents. Refreshes the OAuth grants of the agents' MCP servers that were not refreshed for a week. Classifies 20 of the admin team's channels with Clef (the unclassified first, then those with new activity or a week-old label), listing their members on Slack or Teams first when that is a week old. Pokes the Discord poller, which keeps polling on its own alarm. Enqueues the 50 most recently active channel threads whose message and thread vectors (CONTENT), or full-text items (SEARCH), are older than their latest message, so the first runs after a deploy vectorize and index what was there before. Keeps exactly one /teams/getAllMessages subscription (created,updated,deleted, no resource data) pointed at /teams/notify: renews it to 55 minutes, deletes duplicates, creates it if missing. Graph allows one per app and tenant, so one on a retired origin (or with its lifecycle URL elsewhere) is deleted and recreated; whenever a subscription is created, a catch-up reads every known Teams channel's changes since the gap (messages/delta) and the replies of the threads active in the three days before, and stores them like a backfill (via: catchup). Graph's subscription list is the record; nothing else is stored. |
Nothing below is committed; set it on the Worker.
| Name | Kind | What |
|---|---|---|
SLACK_BOT_TOKEN |
secret | Bot token of the opspile Slack app. Agents with post_message on post with it, which needs the bot scope chat:write. Replies and the inbox read the workspace's custom emoji with it (bot scope emoji:read) |
SLACK_SIGNING_SECRET |
secret | Signing secret of the opspile Slack app |
MS_CLIENT_SECRET |
secret | Client secret of the opspile Entra app registration |
DIAG_TOKEN |
secret | Random value required in x-opspile-diag for /diag/* |
GRAPH_CLIENT_STATE |
secret | Random value our Graph subscriptions carry as clientState; notifications without it are dropped |
SLACK_CLIENT_SECRET |
secret | Client secret of the opspile Slack app (people connecting their Slack account) |
TOKEN_KEY |
secret | 32 random bytes (base64url): encrypts people's OAuth tokens and the agents' MCP server credentials (headers, OAuth grants and client secrets), and signs OAuth state |
ACCESS_ACCOUNT_ID, ACCESS_APP_ID, ACCESS_POLICY_ID |
var | Optional: the Cloudflare account, Access application and allow policy of /inbox and /api. With them, the policy's email list follows the ops teams (staff members only), after every membership change and on the cron |
ACCESS_API_TOKEN |
secret | API token with Access: Apps and Policies Write, for that sync. Required when ACCESS_APP_ID is set |
SIGNIN_DOMAINS |
var | Optional, comma-separated mail domains (usually the staff domains): no-team mode. Anyone with an email there may sign in without being on an ops team, and has the personal view (see "Inbox and Access"). The sync above adds an email-domain include for each to the policy (and never takes one off: remove it in Access after unsetting this) |
MS_CLIENT_ID |
var | Application (client) id of the Entra app |
SLACK_CLIENT_ID |
var | Client id of the opspile Slack app |
SLACK_JOIN_TEAMS |
var | Optional, comma-separated Slack workspace (team) ids: the app joins every public channel there by itself, a new one when it is created and the rest on the cron (40 a run). Needs the bot scope channels:join and the channel_created and channel_unarchive events. Private channels still need an invite; channels the rules mark ignore are left out |
MS_TENANT_ID |
var | Directory (tenant) id |
PUBLIC_ORIGIN |
var | This Worker's https:// origin; Graph sends notifications there |
RETIRED_ORIGINS |
var | Optional, comma-separated origins the Worker answered on before PUBLIC_ORIGIN: pages opened there (/, /inbox…, the OAuth consent and connect pages) get a 301 to PUBLIC_ORIGIN; Slack events, Graph notifications, /mcp and the API still answer; the cron deletes Graph subscriptions that still point there |
WORKER_DOMAINS |
deploy | Optional, comma-separated custom domains of the deployed Worker (cf deploy adds the DNS record and certificate in the domain's zone, which must be in the account); workers.dev stays on |
STAFF_DOMAINS |
var | Comma-separated mail domains whose people count as staff |
ACCESS_TEAM_DOMAIN |
var | https://<team>.cloudflareaccess.com |
ACCESS_AUD |
var | Audience tag of the Access application in front of /inbox and /api |
AGENT_MODEL |
var | Optional: the Bedrock inference profile of the thread agents' model, e.g. us.anthropic.claude-opus-5-5. Agents are off without it. |
AGENT_CHANNELS |
var | Optional: comma-separated channel ids (Slack or Teams) where thread agents run until someone turns them off in the knowledge-base editor (/inbox/kb, which also turns them on elsewhere) |
AGENT_GATEWAY |
var | The AI Gateway (id) agents call Bedrock through |
AGENT_REGION |
var | Optional: Bedrock region, default us-east-2 |
AI_GATEWAY_ACCOUNT |
var | Optional: the gateway's account id; the deploy fills in CLOUDFLARE_ACCOUNT_ID |
AGENT_ANTHROPIC_BETA |
var | Optional: the anthropic-beta features to ask for instead of pi's defaults, none for none |
AGENT_BASE_URL |
var | Optional, for development: an Anthropic Messages endpoint to use instead of the gateway |
TEAMS_APP_ID |
var | Optional: the opspile Teams app's id in the tenant's org catalog. With it, the opspile Teams bot (the Entra app, registered with the Bot Framework) posts the agents' replies in Teams threads (post_message); see "The opspile Teams bot" |
TEAMS_EDITS |
var | Optional: set (1) once the tenant's admins have consented to the delegated ChannelMessage.ReadWrite for the Entra app. Connecting Microsoft then asks for it too (tokens from before are upgraded on refresh, or connect again), so people can edit their own Teams messages from the inbox (POST /api/edit); without it, Teams messages are edited in Teams |
TEAMS_BOT_SERVICE_URL |
var | Optional: the Bot Connector the Teams bot posts through, default https://smba.trafficmanager.net/teams/ |
BROWSER_RUN_TOKEN |
secret | Account API token with Browser Run Write only, for the global browser MCP server (Cloudflare's hosted Browser Run one). Required when AGENT_MODEL is set |
BEDROCK_API_KEY |
secret | Amazon Bedrock API key (bearer token). Required when AGENT_MODEL is set |
AI_GATEWAY_TOKEN |
secret | Optional: a token for an authenticated gateway, sent as cf-aig-authorization; set AI_GATEWAY_TOKEN_REQUIRED=1 at deploy so cf checks it exists |
DISCORD_GUILDS |
var | Optional, comma-separated: the Discord servers (guild ids) to read. With it (and DISCORD_BOT_TOKEN), the DISCORD object polls them; see "Discord" below |
DISCORD_BOT_TOKEN |
secret | The opspile Discord app's bot token. Required when DISCORD_GUILDS is set. Agents with post_message on post with it, which needs the Send Messages and Send Messages in Threads permissions |
DISCORD_STAFF_ROLES |
var | Optional, comma-separated role ids: members with one of them count as staff (Discord shares no email, so the mail domain cannot say) |
DISCORD_HISTORY_DAYS |
var | Optional: how many days back the first poll reads, default 30 |
DISCORD_POLL_SECONDS |
var | Optional: how often the poller asks Discord for new messages, default 60 (at least 15) |
DISCORD_CLIENT_ID |
var | Optional: the Discord app's client id, so people can connect their Discord account (scope identify) and their posts and reactions there count as theirs |
DISCORD_CLIENT_SECRET |
secret | The Discord app's client secret. Required when DISCORD_CLIENT_ID is set |
CLICKHOUSE_URL |
var | Optional: the https:// origin of the ClickHouse HTTP interface the klickhaus export writes to (src/clickhouse.js, "5. Stats (klickhaus)" in docs/ARCHITECTURE.md). The export is off unless all four CLICKHOUSE_* are set |
CLICKHOUSE_DATABASE |
var | The database of opspile_messages_raw |
CLICKHOUSE_USER |
var | The ClickHouse user the export writes as, which may only INSERT into opspile_messages_raw |
CLICKHOUSE_PASSWORD |
secret | That user's password, sent in X-ClickHouse-Key. Required when CLICKHOUSE_URL is set |
MCP_ORIGINS |
var | Optional, comma-separated: browser origins besides PUBLIC_ORIGIN that may call /mcp (requests with any other Origin get 403) |
KLICKHAUS_URL |
var | Optional: klickhaus' origin (or a path under it). With KLICKHAUS_TEAMS, the header has Stats, its opspile.html (the statistics of all threads) in a new tab, on the customer and team on screen |
KLICKHAUS_TEAMS |
var | Optional, comma-separated ops team slugs whose members have klickhaus users: only they see Stats (admins too only as members of one), and /api/me gives them stats: { url } |
LOADER |
Worker Loader | Dynamic Workers: the shells of the agents' computers (the computer tool, Cloudflare Computer's just-bash), and extraction rules that are scripts (without network access; without LOADER they do not run) |
ROOMS |
Durable Object | TeamRoom, one per ops team: live read state |
AGENTS |
Durable Object | ThreadAgent, one per inbox entry with an agent (and per team and customer for seeded knowledge) |
DISCORD |
Durable Object | DiscordPoller, one (poller): polls Discord on its alarm, and remembers each thread's parent channel, each inline reply's conversation, the last message stored per channel and thread, and what it saw of the last day's messages |
RAW |
R2 bucket | Pile 1: helix-opspile-raw (-stage for stage) |
CHANNELS |
R2 bucket | Pile 2: helix-opspile-channels (-stage) |
FILES |
R2 bucket | Copies of the files attached to messages, for agents' computers: helix-opspile-files (-stage). Delete them after 30 days with a lifecycle rule, set once by hand after the first deploy: npx wrangler r2 bucket lifecycle add helix-opspile-files expire-30d --expire-days 30 |
DB |
D1 | helix-opspile (-stage): teams, people, identities, channels, rules, orgs, partner domains, threads, messages, watermarks, mentions, backfills, labels, facts, drafts, agent repos and their mirrors |
AI |
Workers AI | Clef (thread labels, re-threading, the agents' prompt-injection check) and Clairic (facts), bge-base-en-v1.5 embeddings |
VECTORS |
Vectorize | helix-opspile-roots (-stage): one vector per root message. Create it by hand: 768 dimensions, cosine, metadata indexes channel (string) and at (number) |
CONTENT |
Vectorize | helix-opspile-content (-stage): one vector per message a person posted in a channel thread (type: message, with person, staff, author, message) and one per such thread, from all of its messages (type: thread). Create it by hand, with its metadata indexes before the first vector: 768 dimensions, cosine; type, channel, thread, org, team (string), person, at (number), staff (boolean) |
SEARCH |
AI Search | helix-opspile-messages (-stage): full-text search, one item per message of a channel thread, people's and bots' (src/fulltext.js), queried with BM25 (retrieval_type: keyword, every word, the recent boosted by at). Create it by hand before the first deploy that has it (the binding needs it), with its metadata schema: npx wrangler ai-search create helix-opspile-messages --type builtin --hybrid-search --custom-metadata team:text --custom-metadata channel:text --custom-metadata message:text --custom-metadata at:datetime. Then node scripts/replay.mjs --index fills it (the cron does too, fifty threads a run). |
ARTIFACTS |
Artifacts | Pile 3: namespace helix-opspile (-stage), one repo per org (kb-org-<slug>) and per team (kb-team-<slug>), created when a channel is discovered and by the cron for every team; and read-only mirrors of GitHub repositories for the agents (gh-<owner>-<name>-<time>) |
NORMALIZE |
queue | Keys of new pile-1 objects for #6: helix-opspile-normalize (-stage) |
cf deploy creates the buckets and queues when they are missing. The D1 schema is in
migrations/; apply it with cf d1 migrations apply <database id> before the first
deploy, and load teams, people and rules (data, never committed) with cf d1 query.
/inbox and /api (with /inbox/admin and /inbox/kb under them) are one Cloudflare
Access application. People sign in with Slack (Slack's OpenID Connect, through the opspile
Slack app), Microsoft (Entra ID, the opspile app registration, our tenant only) or a
one-time code by email. Whichever they use, the application's allow policy decides, by
email: it lists the staff members of the ops teams, and the Worker keeps it so (see
ACCESS_APP_ID above) after every membership change in the admin pages and on the cron. The
Worker trusts only a verified Cf-Access-Jwt-Assertion whose email is a person in D1 on an ops
team, and shows them the threads of their own teams.
No-team mode. With SIGNIN_DOMAINS, anyone with an email at one of those domains may sign
in too, team or not (the policy gets those domains, see SIGNIN_DOMAINS above). Someone on no
team becomes a person in D1 when they first sign in, if they are not one yet (staff if the
domain is a staff domain), and has only the personal view. The MCP server's list_threads and
search_threads are of that view for them. Without SIGNIN_DOMAINS, only team members get in.
The personal view is everyone's, on a team or not: the conversations in the channels one
posted or was mentioned in, whichever team they are filed under. Anyone reads, replies, reacts,
resolves, drafts and seeds agents in those threads, as well as in their teams' inboxes. The inbox
listens to the rooms of the teams those channels are filed under (/api/me's rooms); in a
room of a team one is not on, the socket is scoped (src/room.js): it hears only about the
threads of the channels of one's personal view (recipients in src/shares.js).
Access returns to the requested path after a Slack or Microsoft login, but without its query
string; pages that need parameters across a sign-in carry them in the path (see the OAuth
consent page below). Both identity providers redirect to
https://<team>.cloudflareaccess.com/cdn-cgi/access/callback, which must be listed in the
Slack app's redirect URLs and the Entra app's web redirect URIs.
/mcp lets an agent outside the browser (Claude, an IDE, a CLI) work the inbox as the person
signed in, with sixteen tools: whoami, list_threads, search_threads (what /api/search
finds, in all of the person's teams), get_thread, post_draft, post_as_user,
post_as_bot, react_to_message, resolve_thread, seed_agent, and the extraction rules and
tech stack of a team (list_extraction_rules, test_extraction_rule, save_extraction_rule,
delete_extraction_rule, save_tech, delete_tech; the changes for the admin team and the
team's managers), so an agent can be asked to update the rules. A thread
is answered three ways, as in the inbox: post_draft saves its shared draft (PUT /api/draft;
base names the version it replaces), which the team sees and sends, with the sources it
rests on, which only the team sees; post_as_user posts as
the person (POST /api/post); post_as_bot posts as the opspile app (POST /api/post/app),
where the channel lets the app post. Both post tools take the shared draft's version as
draft, which uses it up; get_thread shows the draft and can_post_as_bot.
The admin team gets more tools, which nobody else sees in tools/list (for them it is
cacheScope: private) or can call (an unknown tool):
| Tool | What |
|---|---|
get_thread_trace |
Any thread like get_thread, with its agent's work in the same timeline, oldest first: what it was told, what it thought and said, every tool call with its arguments and result, and every MCP server call a code mode script made (mcp_call: arguments and status, not results; the agent keeps its latest 200); its prompt and tool changes; its model, tools, MCP servers, latest turn and token use. From the agent's transcript since its last reset; limit, before (paging) and max_chars. A channel without agents has no trace (its agent is not started to look). |
It speaks MCP 2026-07-28
over Streamable HTTP: stateless, server/discover, version and capabilities in each
request's _meta, header checks (MCP-Protocol-Version, Mcp-Method, Mcp-Name), caching
hints, and plain JSON responses (no SSE, no subscriptions/listen). Clients of the 2025
revisions get initialize and the same tools, without a session. post_as_user and
post_as_bot post at once: the server never asks (no elicitation, which a client can claim and
then decline on its own), so the client's tool approval is the check. get_thread does not
mark anything read.
Sign-in is OAuth 2.1, served by the Worker itself (src/oauth.js): discovery
(/.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server), open
dynamic client registration (/oauth/register, any https redirect URI or localhost), and
/oauth/token (PKCE S256, refresh tokens rotated). There is no list of allowed clients:
/oauth/authorize (public) signs the request into the path of the consent page /api/oauth/consent/…, behind Access like the rest of /api (Access keeps the path through a Slack or Microsoft login but drops the query); it shows who
asks and, in large type, the host the grant goes to, and the person decides. Codes and
tokens are stored hashed in D1 (oauth_*, migration 0015); an access token lives an hour.
/mcp itself is not behind Access; it takes these tokens (or an Access assertion, for
cloudflared access curl). Connect a client to <origin>/mcp.
An agent per thread in the channels where agents are on follows the thread, keeps its shared draft current (a person sends it), and fills the customer's and the team's knowledge bases (OKF; see ARCHITECTURE, "Thread agents"). To turn them on:
- Create an AI Gateway in the Worker's account (
AGENT_GATEWAYis its id). Leave it unauthenticated, or create a token and set the secretAI_GATEWAY_TOKEN. - Create an Amazon Bedrock API key
that may invoke the model's inference profile in
AGENT_REGION, and set it as the Worker secretBEDROCK_API_KEY(npx wrangler secret put BEDROCK_API_KEY --name helix-opspile-prod). - Export
AGENT_MODEL,AGENT_GATEWAYand, optionally,AGENT_CHANNELS(and for CI, add them as repository secrets), apply the migrations and deploy. - Turn agents on or off per channel in the knowledge-base editor (
/inbox/kb, Thread agents): the admin team for every channel, team managers for their teams' channels.
GitHub repositories the agents may read are rows in agent_repos, data like the rules:
INSERT INTO agent_repos (scope, slug, url, note) VALUES ('org', 'acme', 'https://github.com/acme/site', 'their site')
(scope is all, team or org). The cron mirrors them into Artifacts within 20 minutes.
GET /diag/agent?name=<thread key> shows what an agent is doing (and its MCP servers); the
admin team sees everything it did with the MCP tool get_thread_trace.
MCP servers. Each ops team and each customer can have MCP servers for its agents, added in
the knowledge-base editor (/inbox/kb, MCP servers of the team's or the customer's
knowledge base; whoever may edit it): a name, the server's https:// Streamable HTTP URL, what
it is for, its credentials, and optionally the only tools agents may call. Credentials are
either request headers (sealed with TOKEN_KEY, never shown again) or an OAuth sign-in:
Connect sends whoever manages the server to the server's own sign-in (opspile finds its
authorization server, registers itself there unless a client id is given, and uses PKCE), and
the agents then call the server as that person, with what they may do there, until someone
disconnects it. Tokens are refreshed as they expire, one refresh at a time for all agents, and
the cron refreshes grants unused for a week so that refresh tokens stay alive. For a server that
does not let clients register themselves, register one with the redirect URI
<PUBLIC_ORIGIN>/api/kb/mcp/oauth/callback and give its client id (and secret). Adding a server
connects to it and lists its tools. An agent gets the servers of its thread's team and customer,
and calls them only from code mode scripts (await tools.mcp__<name>__<tool>(args)); see
ARCHITECTURE, "Thread agents".
opspile's own /mcp is such a server: added with the URL <PUBLIC_ORIGIN>/mcp and OAuth,
and connected by someone, it gives the agents what that person sees in the inbox (and their
posting tools: limit it with Only these tools, e.g. list_threads, search_threads, get_thread, get_thread_trace).
Global MCP servers. Two servers are built in, set up once for every team, and off until the admin team or a team's managers turn them on, for the team (in the team's knowledge base, MCP servers) or for one channel (Per channel; a channel's switch wins over its team's):
browser, Cloudflare's hosted Browser Run MCP server: pages as markdown or HTML, screenshots, PDFs, links, elements by selector, JSON extraction, crawls. It needs the secretBROWSER_RUN_TOKEN: an account API token with Browser Run Write only.github, GitHub's MCP server, read-only (/mcp/readonly, lockdown mode), as the opspile GitHub App: it reads exactly the repositories the app is installed on. To set it up, a member of the admin team opens MCP servers of any team's knowledge base, fills in the GitHub organization that should own the app (or nothing, for their own account) and Create on GitHub; GitHub asks to confirm, opspile keeps the app and sends them on to install it, on all of an account's repositories or a selection. The app is public, so it can be installed on any account (customers' organizations too), and so anyone can install it: an installation counts only once the admin team Allows it in the same place (and Revoke takes it back). Install elsewhere or change repositories changes where it is installed. With several allowed installations, the server is there once per account (github_<account>). The app only asks for read access; it needs no webhook.
See ARCHITECTURE, "Global MCP servers".
Agent tools. Some of the agents' tools are off until the admin team or a team's managers turn them on, the same way and on the same page (Tools and MCP servers, group Tools): for the team, or for one channel (Per channel; a channel's switch wins over its team's):
post_message: the agent posts a reply in its thread right away, as the opspile bot of the platform, next to keeping the draft. The Slack app needs the bot scopechat:write(reinstall it after adding the scope), the Discord bot the Send Messages and Send Messages in Threads permissions; Teams needs the opspile Teams bot (below). A platform whose bot is not set up does not get the tool.computer: pi'sbashtool, on Cloudflare Computer: a just-bash shell in a Dynamic Worker (theLOADERbinding) over a working directory per thread that keeps its files, withjqandcurl. The shell reaches the internet without a filter, so what an agent read can leave through it; the prompt says not to send anything to sites that are not the customer's or Adobe's.any_thread: the agent reads any thread of the inbox (read_any_thread), by its key or a link to one of its messages. Only the admin team turns it on, one channel at a time, in internal channels only (the org's type is Internal, and the channel is not shared). See ARCHITECTURE, "Agent tools".
The opspile Teams bot. Microsoft Graph lets no app post to a channel; a Bot Framework bot can, in the teams it is installed in. The bot is the opspile Entra app itself:
- Register it with the Bot Framework: an Azure Bot (F0, kind
azurebot) in any subscription, Single tenant, with the existing app (MS_CLIENT_ID, tenantMS_TENANT_ID), messaging endpoint<PUBLIC_ORIGIN>/teams/bot, and the Microsoft Teams channel on. - Add the Graph application permission
TeamsAppInstallation.ReadWriteSelfForTeam.Allto the app, and have an admin grant consent: with it the app installs itself (and only itself) in a team on its first post there. - Build the Teams app package with
node scripts/teams-app.mjs(fromMS_CLIENT_IDandPUBLIC_ORIGIN; written outside the repo) and have a Teams admin upload it to the org catalog (Teams admin center, Manage apps, Upload new app). - Set
TEAMS_APP_IDto the app's id in the catalog (GraphappCatalogs/teamsApps, not the manifest id) and deploy.
See ARCHITECTURE, "Agent tools".
Rows in the rules table map a channel to an ops team, an org and a kind: for each target,
the matching rule with the lowest priority wins. field is channel_id, name,
group_name (the Teams team) or connected (Slack Connect team ids); pattern is a
case-insensitive regular expression and value may use $1. For example
org | name | ^aem-(.+)$ | $1 files aem-acme under the org acme.
The admin team edits teams, members, rules, manual assignments and orgs at /inbox/admin,
which also re-files the channels a change moves. Rules changed in D1 by hand need a replay
with --clear.
Rows in partner_domains (domain, name) mark people from a partner's mail domain (or a
subdomain of it) as partners on their profile card, instead of customers:
INSERT INTO partner_domains (domain, name) VALUES ('agency.example', 'Agency').
Pile 2 is derived, so it can always be rebuilt from pile 1:
node scripts/replay.mjs --dry-run # count what would be replayed
node scripts/replay.mjs --prefix slack/ # replay part of pile 1
node scripts/replay.mjs # after raising FORMAT (src/channels.js): rewrite what renders differently
node scripts/replay.mjs --clear # after changing rules: empty pile 2, replay all
node scripts/replay.mjs --rethread # redo re-threading for every root (after a replay)
node scripts/replay.mjs --rethread --channel <id> # redo one channel's, oldest root first
node scripts/replay.mjs --relabel # label every thread again (titles, Clef, tech stack)
node scripts/replay.mjs --vectorize # embed every channel thread and its messages againThe Entra app needs these Microsoft Graph application permissions, with admin consent:
Team.ReadBasic.All, Channel.ReadBasic.All, ChannelMessage.Read.All, User.Read.All, and
TeamsAppInstallation.ReadForTeam.All (the cron lists each Teams team's installed bots, so replies
can mention them; /diag/teams?bots=1 runs that once). TeamMember.Read.All is optional: with it,
the channel suggestions on the admin pages count the members of a Teams team who never post, and
thread agents can tell who is in a Teams team (find_member, and calling out a mention of someone
who is not); without it the suggestions go by posts and reactions there, and agents say they cannot
tell. On Slack they use conversations.members, which needs the
bot scopes channels:read (and groups:read for private channels).
Files.Read.All (or Sites.Read.All) is optional too: with it, the files attached to Teams
messages (SharePoint and OneDrive) are copied into pile 1 and their images shown in the inbox;
without it they stay links. Images pasted into a message (hosted contents) need only
ChannelMessage.Read.All.
For replies and reactions it also needs the delegated permissions ChannelMessage.Send
(which covers setReaction/unsetReaction on channel messages and inline images in replies),
offline_access and User.Read, for files other than images in replies Files.ReadWrite.All
(asked for only when someone attaches one), and the web redirect URI
<origin>/api/auth/teams/callback; editing one's own
Teams messages from the inbox also needs the delegated ChannelMessage.ReadWrite (admin consent,
then TEAMS_EDITS); for signing in to
the inbox, the web redirect URI of Access's callback and a client secret held by Access. The
Slack app needs the user scopes chat:write, reactions:write and files:write (the last
asked for only when someone first attaches a file to a reply) and the redirect URLs
<origin>/api/auth/slack/callback and Access's callback (Sign in with Slack asks for
openid, email and profile itself); the bot needs team:read for the workspace that links to
Slack open in (team.info), reactions:read for the reaction
events, emoji:read for the workspace's custom emoji (emoji.list, so a custom :name: in a
reply stays as typed and the inbox shows its image; without it Clef replaces no name on Slack
that is not a standard one, since it may be a custom emoji, and the inbox shows them by name),
files:read for images and for the files shared in Slack Connect channels (their
events carry only a stub, which opspile looks up with files.info), and files:write for files
sent as the opspile app. Its Event Subscriptions request URL is
<origin>/slack/events.
A Discord application reads the servers in DISCORD_GUILDS through its bot, read-only:
- Create the application in the Developer Portal;
under Bot, turn on the Message Content intent (privileged; no review below 100 servers)
and turn off Public Bot (first set Installation › Install Link to None). Set the
bot's token as
DISCORD_BOT_TOKEN(cf workers secrets update DISCORD_BOT_TOKEN --worker helix-opspile-prod --type secret_text --text …). - Add the bot to each server with the permissions View Channels and Read Message History
(
https://discord.com/oauth2/authorize?client_id=<app id>&scope=bot&permissions=66560&guild_id=<guild id>). It sees the channels those permissions open to it; private channels need a role. - Export
DISCORD_GUILDS(and for CI, add it as a repository secret), and deploy. The cron starts the poller;POST /diag/discordpolls at once. The first poll readsDISCORD_HISTORY_DAYSof history. - Optionally,
DISCORD_STAFF_ROLESnames the server roles of Adobe people, and withDISCORD_CLIENT_IDandDISCORD_CLIENT_SECRET(OAuth2 redirect<origin>/api/auth/discord/callback) people connect their Discord account in the inbox.
Discord lets only bots post and react through its API, and opspile acts as the person or not at
all: Discord threads have no reply box, reactions or ✅ in the inbox, only a link to reply in
Discord. Rules match the channel's name, and group_name is the server's name.
Discord has no webhooks for messages, only a gateway WebSocket, which would hold a Durable Object in memory (billed by wall-clock time) around the clock. A support server gets a few messages a day, so opspile polls instead: when nothing happened, a poll is two requests and a few hundred milliseconds of the object's time. New messages show within a minute, edits, deletions and reactions within about five.
npm run ui:dev serves the inbox at http://localhost:8787/inbox with fake data and live
reload: saving a page or script under ui/ reloads the page, and saving anything under scripts/
or src/ restarts the server with fresh data. The pages are the same files the Worker serves, and the fake
API uses the same readState, parse and render helpers. D1, R2, the team's Durable Object
and Access are replaced by an in-memory store in
scripts/dev-ui-fixtures.mjs, and you are signed in as Dana.
New fake messages arrive every 25 seconds (node scripts/dev-ui.mjs --no-sim turns that off),
one in five mentioning Dana, who gets a notification once the browser allows them, as for any
message in a thread of theirs (curl -X POST 'localhost:8787/__dev/simulate?mention=1' sends one).
No Cloudflare login or .dev.vars is needed. The knowledge-base editor is at
http://localhost:8787/inbox/kb, over a few made-up knowledge bases in memory
(scripts/dev-ui-kb.mjs), written by the same code as production.
The admin pages are at http://localhost:8787/inbox/admin: the real src/admin.js over an
in-memory D1 with the migrations, seeded from the fake inbox
(scripts/dev-ui-admin.mjs); the fake inbox itself does not see
their changes. On both, Dana counts as a member of the admin team. npm run ui:dev -- --admin
makes her one in the inbox too, with what admins see there: the switch to the editor and the
admin pages, "View as" (read-only) and label review. npm run ui:dev -- --manager signs her in as
the manager of Edge ops instead, in no admin team: what a team manager sees. --member signs her
in as a plain member of her teams: Agents read-only, but for her own settings (whether the agent's
thinking shows in her inbox).
node scripts/dev-ui.mjs --big --no-sim --latency=400 serves a prod-sized fake inbox (some 250
threads from forty invented customers, a quarter of them 50–150 messages long) and makes every
/api answer wait 400 ms, as production's do. node scripts/perf-ui.mjs then drives a headless
Chrome over it with the CPU four times slower and prints the web vitals: load (LCP, time to a
usable list, long tasks), INP of the everyday interactions (opening threads, j/k, filters,
sorting, typing, @-mentions, profile cards), CLS, and what a live update costs
(--runs=5, --origin=…, --profile[=step], --trace=step). Fake data only: it refuses any
origin but localhost.
To point the preview at production instead, sign in to the inbox in Chrome with remote
debugging on (port 9222), then run npm run ui:session -- <inbox origin>. It copies the Access
session cookie into .dev-ui-session.json (gitignored, never printed), and the preview switches
to the production API, read-only: writes are refused and the page's banner says so. Delete the
file, or run npm run ui:session -- --clear, to go back to fake data.
Within a status, a team's inbox is ordered by the model in ui/rank.js: boosted trees (150
trees of depth 3, some 20 KB) over some fifty features of a thread, computed in the browser from the inbox entry (how long since the
last message, whether it waits on us, a customer's first message nobody at Adobe has answered,
Clef's urgency and resolved, a staff ✅, the kind, unseen by Adobe, reactions, what the last
message asks, …), in log-odds of "the team should turn to this one first". It is a
Bradley–Terry model whose strength is that score, learned from games of three kinds:
- Judges. A sample of threads (
pull), paired in an Elo tournament where Clef says which comes first (duel); Haiku and Sonnet judge some of the pairs (llm), and you judge some (judge). Each pair is decided by the highest of those who judged it: you, then Sonnet, then Haiku, then Clef. - Picks. When someone opens a thread by clicking it in the list, the inbox sends the threads
on screen then, their places and their features to
POST /api/rank/pick(tablerank_events; not while viewing as someone else or searching; only in your own views, of a thread that exists), with each one's status pile. The cron deletes picks older than 90 days (RANK_EVENTS_DAYSinsrc/rankevents.js): by then the weekly training has fetched them into.rank/events.jsonl. The clicked thread wins over each of them: fully over those above it, less over those below it, which may not have been looked at. The model orders threads within a pile, so a pick only wins over the others of its pile (picks recorded before the piles were sent: over all of them). - Replies. When the same person answers several customers on the same day, the customer answered sooner wins over the one answered later, each thread as it was when its customer wrote.
Each source is a share of the training (DEFAULT_SHARES in scripts/rank-combo.mjs: picks
30%, replies 20%, Sonnet 20%, you 15%, Clef 10%, Haiku 5%; a source without games gives its share
to the others), so Clef's thousands of games do not drown out the rest, and what the teams do
weighs more than what anyone says. The steps, each
reading what the ones before wrote into .rank/ (gitignored; it holds production threads, so
it stays on your machine):
npm run rank -- pull [--n all] [--alerts 0.1]takes the threads of your teams' inboxes and your personal view (for the admin team, every team's, read as one of its members), with their messages, from the production API, read-only, with the sessionnpm run ui:sessioncopied. Alerts are a tenth of the sample. The sample before, and what was judged on it, moves to.rank/<when it was pulled>/, and is still trained on.npm run rank -- labelsfetches today's kind of every thread trained on, into.rank/labels.json. The training uses it instead of the kind a thread had when it was pulled or picked: a kind is a thread's subject, and when what the kinds mean changes (peopleandmeetingsplit offaccessandother), the judgements and picks made before stay good. Run it after a relabel (node scripts/replay.mjs --mode prod --relabel);weeklyruns it.npm run rank -- duel [--rounds 12]plays the tournament with Swiss pairings: Clef reads two threads (their messages and what the inbox shows of them) and the team's policy (POLICYinscripts/rank-lib.mjs), and says which comes first, asked both ways round. More rounds add to the games played; changing what Clef is shown (PROMPT_VERSION) asks again. It calls Workers AI withCLOUDFLARE_ACCOUNT_ID(the Worker's account) andCLOUDFLARE_API_TOKEN, or the token ofwrangler login.npm run rank -- llm [--judges haiku,sonnet] [--pairs 200]has Claude models (on Bedrock, with a Bedrock API key inAWS_BEARER_TOKEN_BEDROCK, inAWS_REGION) judge the same games as Clef, shown the same, asked both ways round, with a sentence of why: the pairs you judged, then--pairsmore.--judges name=<model id>adds others.npm run rank -- judge [--port 8796]shows two threads of Clef's games side by side in the browser, and you pick the one the team should turn to first (← →, ↓ for "same",sto skip,uto undo). Then it shows what Clef, the Claude judges (with their reasons), the model and the old score said, and how often each agrees with you so far (Clef's also by how sure it was, and where asking it the other way round changed its mind). It can show any pair, pairs where one or both threads are still pending, the ones Clef was unsure of, or where the model and Clef, or the model and the old score, disagree; "Blind" hides the answers. Your answers go to.rank/human.jsonl. It follows a new sample by itself.npm run rank -- eventsfetches the picks recorded since the last time (GET /api/rank/events, admin team) into.rank/events.jsonl.npm run rank -- expertsdoes the same for the expert finder's lists and what was done with them (GET /api/experts/events) into.rank/expert-events.jsonl, for a better finder to be trained on later;weeklyruns both.npm run rank -- train [--model boost|linear] [--trees 150] [--depth 3] [--shares click=0.3,…|counts] [--weights …] [--without a,b] [--dry-run]fits the model on all samples, picks and replies, prints how often the model picks the winner of each source's games about a thread it was not trained on (five folds by thread: no game with a held-out thread is trained on, whatever its source), next to the hand-made score it replaces (classicScoreinui/inbox.js), and how often each judge agrees with you, and writes the model intoui/rank.js(between// MODEL:and// END MODEL). A feature seen in fewer than--min-supportthreads is not learned, and the policy's signs (SIGNSinscripts/rank-combo.mjs: resolved lowers a thread; waiting on us, urgency, unseen by Adobe and a first message nobody answered raise it) are kept: the trees are monotone in them (a split on such a feature must order its leaves so, as XGBoost's monotone constraints do); in a linear model (--model linear, L2--l2 30) a weight the games would turn the other way is held at 0, and the training says which.--withoutleaves features out, to see what they are worth. Only feature names, thresholds and numbers are committed. A tree is[feature, threshold, left, right]or a leaf: the browser walks them (some 4 µs a thread), and D1 could as nestedCASEexpressions.npm run rank -- compare [--trees 150] [--depth 3]puts the linear model next to boosted trees and a random forest (scripts/rank-trees.mjs, learned from the same games with the same pairwise loss), on the same folds: held-out accuracy per source, log loss, size and the time to score a thread.npm run rank -- weekly [--pr]does it all once a week: a fresh session from the signed-in browser, the picks, a new sample, today's kinds, the tournament, the Claude judges on part of it, then the training; with--pr, the new weights go to a pull request against main (from a worktree of it, and only when main computes the same features). A bb automation runs it on Mondays.npm run rank -- behaviorlooks at what the teams do instead: for every thread of the sample, whether someone on its team opened it in the inbox and how many read it, and for every time a customer wrote, whether and how soon Adobe answered; by kind, urgency, who has the next move, size, age, platform, team, and what the message says (a question, alarm words, thanks, its length, the time and day), with the odds of an answer within a day from a logistic regression, and how well the model, Clef's ratings and the old score pick the threads that were opened. It writes.rank/behavior.md.
A feature is added in features() of ui/rank.js, which the browser, the picks and the
training all run; train again after changing one. Picks keep the features they were sent with,
so a new feature is learned from picks only once the inbox computes it. Rank.explain(entry)
in the console lists what makes up a thread's score. Without a model (const MODEL = null) the
inbox orders by classicScore.
The inbox registers WebMCP tools, so an agent in the browser (Gemini in Chrome, an extension) can work the inbox through the same UI as the person signed in, instead of clicking through the DOM. Every tool changes what is on screen (filters, the open thread, the reply box) and every write goes through the same endpoints as a click, as that person. A read-only "view as" refuses writes.
| Tool | What |
|---|---|
get_inbox_state |
Viewer, team, filters, open thread, and counts per filter value |
filter_threads |
Set filters (and values to exclude), sort and team |
list_threads |
The threads shown, most relevant first |
search_threads |
Search on screen, as the search box does; the results, best match first, with the message that matched |
open_thread |
Open a thread (marks it read) and return its latest messages |
draft_reply |
Put a reply in the reply box for the person to send (in this browser only) |
post_draft |
Save a reply as the thread's shared draft, for the team (base: the draft it replaces; sources: what it rests on, which only the team sees) |
post_as_user |
Post a reply as the person |
post_as_bot |
Post a reply as the opspile app, where the channel lets the app post |
react_to_message |
Add or take off a reaction |
resolve_thread |
✅ on the first message, or take it off again |
Tool output that carries message text or titles is marked untrustedContentHint, and
post_as_user and post_as_bot are marked consequentialHint. To try them, turn on
chrome://flags/#enable-webmcp-testing and open the inbox (npm run ui:dev works) with the
Model Context Tool Inspector
extension.
CI (.github/workflows/ci.yml) runs lint, tests and a build
check on every pull request. A push to main applies the D1 migrations and deploys
helix-opspile-prod. It authenticates with the repository secrets CLOUDFLARE_API_TOKEN
(an account token scoped to Workers scripts, R2, Queues, D1, Artifacts and Workers
Observability; it expires after a year) and CLOUDFLARE_ACCOUNT_ID, and reads the plain-text vars from the
secrets of the same name. Migrations must be additive, because they run before the new code
is live.
To deploy by hand instead: deploys go through the new Cloudflare CLI,
cf, which reads cloudflare.config.ts (--mode prod or --mode stage selects the Worker). cf deploy and cf dev also build the inbox's
markdown bundle and the knowledge-base editor's (src/ui-markdown.bundle.js and
src/ui-editor.bundle.js, not committed; npm run build by hand).
npm install
npm run lint
cf auth create <profile> && cf auth activate <profile> # once, for the target account
printf 'MS_CLIENT_ID=…\nMS_TENANT_ID=…\nPUBLIC_ORIGIN=https://…\nSTAFF_DOMAINS=…\n' > .env
npm run deploy:prod # or deploy:stagecf has no keep_vars: the plain-text vars are part of every deploy, so the config reads them
from the environment or .env and refuses to deploy without them. Secrets are declared by
name only; cf deploy checks that they exist on the Worker and keeps their values. cf
cannot set a single secret yet, so use
npx wrangler secret put SLACK_BOT_TOKEN --name helix-opspile-prod, or pass
--secrets-file to cf deploy (required for the first deploy of a new Worker). For
cf dev, put the bindings in an uncommitted .dev.vars.