Web app ↔ Hermes communication protocol Status: first standalone extraction of this protocol. 08/28/26. Source of truth: /Users/paul/projects-4/t15-identity-boundary-runbook/api/submit.js, built and verified live against production Slack during the T15 build, 08/27–28/26. Not the source of truth: RUNBOOKTEMPLATE_fullrefit_v1_072526.md §8–9. That document describes this protocol but couples it to a specific four-card decision-instrument UI, and it proposes a companion skill and a learning loop that were never built. Anything here that differs from that document, this document wins. Scope. This is the wire protocol only — how any web app tells Hermes something happened and gets a verifiable answer about whether Hermes received it. It has nothing to do with gate panels, accordions, progress rails, or step cards. A single-page tool with one button can implement this in full. 1. What this protocol is for A web app needs to tell Hermes three kinds of things: Progress — something happened, informational, no response expected. Decision or approval needed — a human answered a question, or is asking Hermes to do something, and Hermes should act on it. Its own health — whether it’s wired up to deliver at all, without sending noise. It needs to do this in a way where: The message reaches the right Slack bot, verified by identity, not by variable name. A silent failure is impossible. Slack can return HTTP 200 on a refusal; this protocol never treats that as success. A double-click or a retry doesn’t create two events. Nothing secret ever appears in the page, the response, or the message itself. 2. The identity-verification requirement (read this before anything else) Every send calls auth.test first. Never trust a token by variable name, never trust that a token that used to be correct still is. This is not caution for its own sake. It is a directly observed failure mode: this project’s own ~/.env.1p.tpl had SLACK_BOT_TOKEN declared twice, resolving to two different apps depending on declaration order, and a separate bundled-credential item silently dropped its real token behind a dead reference. A deploy that trusted the variable name would have posted as the wrong bot, or posted nothing, with no error anywhere. async function resolveIdentity(botToken, expectedBotUserId) { if return { ok: false, matches: false, error: 'no_token', summary: null }; const r = await fetch('https://slack.com/api/auth.test', { method: 'POST', headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/x-www-form-urlencoded' } }); const body = await r.json().catch(() => ({ ok: false, error: 'non_json_response' })); if return { ok: false, matches: false, error: body.error || 'unknown', summary: null }; return { ok: true, matches: body.user_id === expectedBotUserId, error: null, summary: { botUserId: body.user_id, botName: body.user, team: body.team, teamId: body.team_id } }; } If matches is false, refuse to send. Return 502 wrong_bot_identity. Never fall back to sending anyway, never guess. The refusal names the expected and actual bot user ids so a human can fix the token, and names nothing about the token’s value. 3. The delivery-truth requirement chat.postMessage returns HTTP 200 on refusal. The body is {"ok":false,"error":"not_in_channel"} or similar. Reading the HTTP status instead of body.ok produces a Send button that reports success and delivers nothing — the single worst failure mode for anything meant to hand information to another system, because the human stops looking once they see success. async function postMessage(botToken, channelId, text) { const r = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${botToken}` }, body: JSON.stringify({ channel: channelId, text, unfurl_links: false }) }); const body = await r.json().catch(() => ({ ok: false, error: 'non_json_response' })); return { status: r.status, ok: r.ok && body.ok === true, // <-- success is body.ok, never r.ok alone slackError: body.ok ? null : (body.error || 'unknown'), ts: body.ts || null }; } Generalize this: an HTTP status is never proof of an application-level outcome. Any transport this protocol adds later (email, a different chat platform) must be checked at its own success semantics, not the wrapper’s. 4. Transport: routes, in priority order # Route Requires Notes 1 Slack Web API, bot token SLACK_BOT_TOKEN (or SLACK_BOT_TOKEN_) + a channel id Default. A bot token can post to any channel it’s a member of, so routing is config, not redeploy. Requires the bot actually be in the channel. 2 Slack incoming webhook SLACK_WEBHOOK_URL_ → SLACK_WEBHOOK_URL is the calling app’s id, uppercased, non-alphanumerics as underscores. Specific env var beats generic; generic beats built-in default. This lets one Vercel/Worker/whatever deployment host multiple apps’ credentials without collision, and lets any one app override the shared default without touching the others. Never call conversations.join. Channel membership is a human decision. If the bot isn’t in the channel, the correct behavior is a named, fixable error — not_in_channel — not silent self-repair. 5. Message envelope Two variants, both plain text (not blocks/attachments — this keeps the wire format trivial to construct and trivial for Hermes to parse without a Slack SDK on the reading side). 5.1 Progress — informational, no response needed FOR HERMES: Message type: progress Source: Task: Status: progress | complete Subject artifact: - File: - Path: - SHA-256 at build: - Project: @ - Task: Event: | Progress: / complete Notes: 5.2 Decision or approval — Hermes should act FOR HERMES: HUMAN RESPONSE REQUEST Message type: decision | approval_needed Agent: Task: Request type: multiple_choice | free_text Question: Choices: -