// channels.ts — built-in channel factories (http / slack / crisp). // // Web-standard (Request/Response, crypto.subtle, fetch, TextEncoder, btoa — all // globals, zero node:*), so they run on native AND edge. Unlike the PoC, these // are FACTORIES that take secrets as options: the app injects them from its // environment (process.env on native, env bindings on edge), keeping the channel // itself portable. An app's agent/channels/slack.ts is then a one-liner: // export default slackChannel({ signingSecret: process.env.SLACK_SIGNING_SECRET! , botToken: ... }) import { DeliverUnsupportedError, type Channel, type ChannelContext, type DeliveryTarget, type ResumeDeliveryTarget } from "./agent-config"; import type { FileRef, FileStore } from "./agent-files"; import type { InboundEvent, InputRequest, ProactiveTrigger, Tool, ToolContext, TurnEvent } from "./agent-runtime"; import type { Principal } from "./context"; // Re-export the normalized inbound envelope from where channel authors live, so an // adapter can `import { type InboundEvent } from "@junejs/core/channels"` alongside // the factories it builds on. Canonical definition lives in agent-runtime (ToolContext // carries it); this is a convenience re-export at the channel entry point. export type { InboundEvent } from "./agent-runtime"; // The outbound delivery target for receive()/channel.deliver — re-exported here so a proactive // caller can type its target alongside the channel factories it imports from this entry point. export type { DeliveryTarget } from "./agent-config"; const enc = new TextEncoder(); // A webhook ACKs fast (within the platform's timeout) and does the real work — run // the turn, post the reply back — AFTER responding. That trailing promise must // survive the response: on the edge the host supplies `ctx.waitUntil` (keeps the // isolate alive); on native there is none and a floating promise runs to completion // on its own. Either way `.catch` routes failures to onError so nothing rejects // unhandled. function runBackground(ctx: ChannelContext, work: () => Promise, onError?: (err: unknown) => void): void { // Promise.resolve().then(work) so even a SYNCHRONOUS throw from work (a non-async // work fn) turns into a rejection the .catch below handles — the fast-ACK path must // never see an exception escape. (Both built-in callers are async today, so this is // defensive completeness for any future work shape, not a live bug.) const p = Promise.resolve() .then(work) .catch((err) => { // A broken app-supplied onError must NOT destabilize the ACK path either: if it // throws, an edge waitUntil task would fail and a native floating promise would // become an unhandled rejection. Swallow it so this promise can truly never reject. try { onError?.(err); } catch { /* error reporting failed — nothing more to do */ } }); ctx.waitUntil?.(p); } function hex(buf: ArrayBuffer): string { return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } // Constant-time string compare (equal lengths only) — avoids leaking the MAC via // timing. function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } async function hmacSha256Hex(secret: string, message: string): Promise { const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return hex(await crypto.subtle.sign("HMAC", key, enc.encode(message))); } // Parse a webhook body that has ALREADY passed signature verification, tolerating a // malformed payload. A signed-but-unparseable body is authentic yet permanently // unprocessable (retrying yields the same bytes), so the caller ACKs 200 and drops it // rather than throwing — an uncaught throw here would surface as a 5xx and make the // platform (Slack/Crisp) redeliver the same broken event forever. function tryParseJson(body: string): T | undefined { try { return JSON.parse(body) as T; } catch { return undefined; } } // Replay/freshness guard: reject a request whose timestamp is more than 5 minutes from // now. `ts` may be epoch seconds (Slack) or milliseconds (Crisp), so normalize by // magnitude — a value past ~1e11 can only be milliseconds (1e11 s ≈ year 5138). function timestampFresh(ts: string, toleranceSec = 300): boolean { const n = Number(ts); if (!Number.isFinite(n) || n <= 0) return false; const tsSec = n > 1e11 ? n / 1000 : n; return Math.abs(Date.now() / 1000 - tsSec) <= toleranceSec; } // ── exported primitives (composability floor) ──────────────────────────────── // Security-critical building blocks, exported so a hand-rolled channel never has to // re-implement crypto: verify a signed request, then normalize its payload. A fork // reads the raw body once and calls these — ~30 lines of domain logic, no HMAC of its // own. `tryParseJson` and `normalizeSlackEvent` (below) round out the set. // Verify a Slack request signature (v0 = HMAC-SHA256 of "v0:{ts}:{body}") AND its // freshness (±5 min replay guard). Pass the raw body exactly as received. export async function verifySlackSignature(signingSecret: string, timestamp: string, body: string, signature: string): Promise { if (!signingSecret || !timestamp || !signature) return false; if (!timestampFresh(timestamp)) return false; return timingSafeEqual("v0=" + (await hmacSha256Hex(signingSecret, `v0:${timestamp}:${body}`)), signature); } // Verify a Crisp plugin hook signature (HMAC-SHA256 of "[{ts};{body}]") + freshness. export async function verifyCrispSignature(signingSecret: string, timestamp: string, body: string, signature: string): Promise { if (!signingSecret || !timestamp || !signature) return false; if (!timestampFresh(timestamp)) return false; return timingSafeEqual(await hmacSha256Hex(signingSecret, `[${timestamp};${body}]`), signature); } // Re-exported for hand-rolled channels: parse a signed body tolerating malformed input, // and check a timestamp's freshness. (Definitions above.) export { tryParseJson, timestampFresh }; // ── channel extension seams (shared by slack + crisp) ───────────────────────── // An observe/mirror hook: called for EVERY signature-verified inbound webhook event — // before the turn's loop guard, so it also sees operator/bot/non-text events the turn // path drops. Runs in the background with the same fast-ACK discipline as the turn // (its own error swallowed via onError; never blocks the 200). `raw` is the untouched // platform payload; `event` is the normalized envelope when the event maps to one. This // is the seam an app uses to mirror a conversation into its own store (a RAG source of // truth) WITHOUT forking the channel — it inherits all the signature/replay/parse // hardening for free. export type ChannelObserver = (e: { raw: unknown; event?: InboundEvent }, ctx: ChannelContext) => Promise | void; // How the channel treats the agent turn. "respond" (default) = the built-in behavior: // an eligible user message runs a turn and the reply is posted back. "observe" = shadow // mode: NEVER run a turn or post a reply — only `onEvent` fires. Pure ingestion, zero // LLM cost, the channel never talks back to the platform. export type ChannelMode = "respond" | "observe"; // A typed, single-kind observer (see `on` below): unlike onEvent it fires only for its // kind and only when a normalized event exists (post bot/loop guards), so `event` is // non-optional and no `event.kind` demux is needed. export type KindObserver = (event: InboundEvent, ctx: ChannelContext) => Promise | void; // A webhook delivery the channel turned away without doing any work (#90) — the // silent failure modes a live debug session burns hours on. `bad_signature` covers // every auth failure (HMAC mismatch, stale timestamp, bad URL key — the response is // a 401 either way, and wrangler tail's pretty format shows "Ok" for it); // `malformed_body` is a signature-valid but unparseable body (ACKed 200 so the // platform won't retry-storm); `unrouted_interaction` is a verified interaction // payload (a click, a shortcut, a view submission) no built-in branch nor // onInteraction claimed. Observability only: firing the hook never changes the // response. export type ChannelRejection = { kind: "bad_signature" | "malformed_body" | "unrouted_interaction"; detail?: string }; // The extension opts both channel factories accept, so an app can sit on the built-in // instead of forking its webhook. `accept` gates a verified event before any work (an // allowlist lives here) — returning false ACKs 200 and ignores it. `onRejected` is // the debug seam for deliveries the channel turns away silently (see ChannelRejection). type ChannelExtensions = { mode?: ChannelMode; accept?: (raw: unknown) => boolean; onEvent?: ChannelObserver; onRejected?: (rejection: ChannelRejection, req: Request) => void; }; // Preflight diagnostics (#90): the answer to an hour-long "why doesn't my observer // fire" hunt, as one structured call. `counters` are per-isolate (since construction — // workerd isolates recycle, so treat 0s as "none since this isolate started", which is // exactly the live-debugging question anyway). `hints` are the human one-liners: // "app_mention received: 0 — check Socket Mode / Request URL". export type SlackDiagnosis = { auth: { ok: boolean; error?: string; team?: string; botUserId?: string }; scopes: { granted: string[]; missing: { scope: string; neededFor: string }[] }; counters: { since: number; // epoch ms of channel construction (this isolate) // per normalized kind; un-normalizable events count under their raw Slack type — // "arriving but filtered" and "not arriving" read differently, deliberately eventsReceived: Record; // claimed = a built-in branch took it (june_feedback / june_input:*); // appHandled = delivered to onInteraction; unrouted = nobody took it (a rejection) interactions: { claimed: number; appHandled: number; unrouted: number }; rejections: Record; // redeliveries of an event_id already handled here (#170) — ACKed and dropped duplicates: number; }; hints: string[]; }; // The counter registry behind SlackDiagnosis (#90), keyed by mount path — module // scope so counts survive per-request channel reconstruction (see the note at the // slackChannel use site). Exported for tests only via resetSlackCounters. const slackCounterRegistry = new Map(); function slackCountersFor(path: string): SlackDiagnosis["counters"] { let c = slackCounterRegistry.get(path); if (!c) { c = { since: Date.now(), eventsReceived: {}, interactions: { claimed: 0, appHandled: 0, unrouted: 0 }, rejections: { bad_signature: 0, malformed_body: 0, unrouted_interaction: 0 }, duplicates: 0 }; slackCounterRegistry.set(path, c); } return c; } // Test seam: isolate suites from each other's counts (a fresh workerd isolate does // this for free; a long-lived test process needs it explicitly). export function resetSlackCounters(path?: string): void { if (path === undefined) { slackCounterRegistry.clear(); slackSeenRegistry.clear(); } else { slackCounterRegistry.delete(path); slackSeenRegistry.delete(path); } } // Event ids already handled (#170), per mount path like the counters. Slack redelivers an // Events API event (x-slack-retry-num 1..3, over ~5 minutes) with the SAME envelope event_id // when a delivery didn't get a 2xx within 3 s — a slow ACK, an error response, or a // connection/TLS failure; handling the redelivery again ran a second turn and posted a // duplicate answer. An id is recorded only once the delivery is ACKed (see the webhook), so // a delivery that failed before its ACK is retried and handled, never dropped. Per isolate, // so best effort on the edge (a retry landing on a different isolate isn't caught); bounded // by a TTL past Slack's retry window and a size cap (Map order = insertion order, oldest // evicted first). const SLACK_SEEN_TTL_MS = 10 * 60_000; const SLACK_SEEN_MAX = 5_000; const slackSeenRegistry = new Map>(); function slackSeen(path: string, eventId: string, now = Date.now()): boolean { const at = slackSeenRegistry.get(path)?.get(eventId); return at !== undefined && now - at < SLACK_SEEN_TTL_MS; } function slackRemember(path: string, eventId: string, now = Date.now()): void { let seen = slackSeenRegistry.get(path); if (!seen) slackSeenRegistry.set(path, (seen = new Map())); seen.delete(eventId); // re-insert at the tail when an expired id comes back seen.set(eventId, now); for (const [id, t] of seen) { if (seen.size <= SLACK_SEEN_MAX && now - t < SLACK_SEEN_TTL_MS) break; seen.delete(id); } } // Guarded call: a broken app-supplied onRejected must never destabilize the // webhook's response path (the exact posture of runBackground's onError guard). // `(r, req) => void` admits an async implementation (a Promise is assignable to // void), so a rejection there must be contained too — not just a sync throw. function reject(opts: { onRejected?: (r: ChannelRejection, req: Request) => void }, req: Request, rejection: ChannelRejection): void { try { const out = opts.onRejected?.(rejection, req) as unknown; if (out && typeof (out as Promise).then === "function") (out as Promise).then(undefined, () => { /* reporting failed */ }); } catch { /* reporting failed — nothing more to do */ } } // ── http — a generic Web channel: POST /message runs a turn; optionally serve // /mcp (pass your app's mcpHandler) so the same directory is also an MCP server. export function httpChannel(opts: { path?: string; mcp?: (req: Request) => Promise } = {}): Channel { const messagePath = opts.path ?? "/message"; return { name: "http", fetch(ctx) { return async (req: Request): Promise => { const url = new URL(req.url); if (opts.mcp && url.pathname === "/mcp") return opts.mcp(req); if (url.pathname === messagePath && req.method === "POST") { const { message, session } = (await req.json()) as { message: string; session?: string }; return Response.json({ text: await ctx.run(message, { session }) }); } return new Response(`${ctx.agent.name} — POST ${messagePath}`, { status: 404 }); }; }, }; } // Text from a tool result (a filename) placed in a message the channel posts: Slack reads // `<…>` as a mention or link (``, `<@U…>`) and `&` as an entity, so escape exactly // those three, as Slack's formatting docs require. function escapeMrkdwn(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } // One upload per file per render: two tool calls may return the same (content-addressed) file. function collectFile(into: FileRef[], file: FileRef): void { if (!into.some((f) => f.id === file.id)) into.push(file); } // ── slack — Events API inbound (signed) → durable turn → chat.postMessage out. // Slack signs each request v0=HMAC-SHA256("v0:{ts}:{rawBody}"). Fast-ACK within // 3s and run the turn in the background; the bot_id/subtype guard prevents the // agent replying to itself. // // Beyond replying, the channel gives the agent READ + WRITE capabilities on the // workspace via `tools` (merged into agent.tools by defineAgent): read a thread's // replies, list who reacted with which emoji, resolve a user id to a name, and add a // reaction. Each tool defaults its target — channel / thread / message ts — from the // CURRENT turn's InboundEvent (ToolContext.event), so the model can call // `slack_read_thread` with no arguments and get the thread it's already in. These need // a bot token with the matching scopes (channels:history / groups:history, // reactions:read, reactions:write, users:read). // // `events` selects which inbound events become turns: message + app_mention by default // (a text turn). Add "reaction_added"/"reaction_removed" to have the agent react when // someone adds/removes an emoji (opt-in — most apps don't want a turn per reaction). A // reaction turn carries no text, so a synthesized note ("<@U> added :tada:") is the // userText and the reaction target rides on event.reaction. `botUserId` is the loop // guard for reactions: with it set, the bot's OWN reactions (e.g. from slack_add_reaction) // don't trigger a turn — the bot_id/subtype guard already covers self-messages. export type SlackEventKind = "message" | "app_mention" | "reaction_added" | "reaction_removed"; // Where a Slack event happened (#212): public channel, private channel, DM, group DM. export type SlackChannelType = "channel" | "group" | "im" | "mpim" | "unknown"; // A normalized Slack event: the shared InboundEvent plus the Slack-only facts a // respondWhen predicate or an `on` observer decides on. export type SlackNormalizedEvent = InboundEvent & { /** From the event's `channel_type`. `app_mention` events don't carry one: `"unknown"`. Neither do reactions. Either reads `"im"` in a `D…` (DM) channel. */ channelType: SlackChannelType; }; // A feedback_buttons click, normalized: who rated which streamed reply, tied back to the // turn/session the buttons were minted with (stopStream embeds them in the button value — // a PROACTIVE turn's session is caller-chosen and couldn't be re-derived from the thread). export type SlackFeedback = { rating: "positive" | "negative"; turnId?: string; session?: string; user?: { id: string }; channelId?: string; threadId?: string; messageTs?: string; }; // Slack's native AI feedback buttons (one context_actions block): the streamed path // attaches these at chat.stopStream when opts.feedback is on, and each button's value // ties the click back to {rating, turnId, session} for the june_feedback interaction // branch — so clicks land in onFeedback with no extra wiring. Exported (#88) so a // message the app posts ITSELF (channel.post / chat.postMessage — e.g. an assessment // card) can carry the same buttons as the streamed reply: append to the posted // message's blocks, and clicks route through the same onFeedback. export const feedbackBlocks = (turnId?: string, session?: string) => [{ type: "context_actions", elements: [{ type: "feedback_buttons", action_id: "june_feedback", positive_button: { text: { type: "plain_text", text: "Good response" }, value: JSON.stringify({ rating: "positive", turnId, session }) }, negative_button: { text: { type: "plain_text", text: "Bad response" }, value: JSON.stringify({ rating: "negative", turnId, session }) }, }], }]; // What slackChannel's resolveIdentity receives: the sender facts carried INSIDE the // signature-verified event payload. `userId` is Slack's own assertion of which user acted // (absent on the rare kinds that carry no user). These are trustworthy as PLATFORM // identity ("this Slack user did this"); what they mean to the app — operator, staff, // nobody — is exactly what the resolver decides. // // ⚠️ Workspace membership is NOT one of these facts. `teamId` is the ENVELOPE workspace — // where the app is installed and received the event — and with Slack Connect an external // user posting in a shared channel arrives under that same envelope. `senderTeamId` // carries the sender's home-workspace id when Slack includes one on the event // (`user_team`/`source_team`/`team`); equality with `teamId` supports same-workspace, but // ABSENCE proves nothing. A resolver granting elevated (staff/operator) principals must // therefore verify membership explicitly: an explicit user-id allowlist, a users.info // lookup over the bot token, or restricting the surface to non-shared channels — not the // envelope team alone. export type SlackIdentity = { userId?: string; /** The ENVELOPE workspace (app installation) — NOT proof of the sender's membership. */ teamId?: string; /** The sender's home workspace, when Slack includes it on the event; absent ⇒ unproven. */ senderTeamId?: string; channelId: string; threadId?: string; kind: SlackEventKind; ts: string; }; export function slackChannel(opts: { signingSecret: string; botToken: string; path?: string; apiUrl?: string; events?: SlackEventKind[]; // Which of the subscribed `events` actually drive a turn + reply; the rest only reach // `onEvent`. Decouples noticing an event from responding to it, per KIND — e.g. // events:["app_mention","reaction_added"], respondTo:["app_mention"] runs a turn for a // mention but treats a reaction as a deterministic observe (no LLM). Defaults to all of // `events` (every subscribed kind responds — the prior behavior). `mode:"observe"` // forces this empty (respond to nothing). respondTo?: SlackEventKind[]; /** Per event: whether a kind listed in `respondTo` gets a turn. Observers still fire. Default: always. */ // Checked after the respondTo kind check and after resolveIdentity (event.principal is // set) — never for an event `accept` rejects or a redelivery. `raw` is the envelope // `accept` and `onEvent` see. E.g. answer mentions // and DMs but not every channel message: // respondTo: ["app_mention", "message"], // respondWhen: (e) => e.kind === "app_mention" || e.channelType === "im", // A throw is reported to onError and counts as "no" (the observers already ran). respondWhen?: (event: SlackNormalizedEvent, raw: unknown) => boolean; // Per-kind observers: `on[kind]` fires (background) only for that kind, only when a // normalized event exists — no onEvent-style `event.kind` demux or `event?` guard. // Coexists with onEvent (which stays the "observe everything incl. un-normalizable" // firehose). A kind present here is auto-subscribed (see the `events` derivation). on?: Partial Promise | void>>; botUserId?: string; // The identity seam (the Slack sibling of crispChannel's resolveIdentity): map the // PLATFORM-VERIFIED sender to the app's own Principal before any consumer of a // normalized event runs. Unlike Crisp — where the webhook's user fields are // client-writable hints and identity must be PULLED as verification evidence over // REST — a Slack event's sender ids arrive inside the signature-verified payload // itself: "this Slack user sent this" is already Slack's own assertion, so there is // no evidence fetch and no extra latency. What the resolver decides is what that // platform identity MEANS to the app: map a known user to an operator/staff // Principal, or return null/undefined to leave the turn anonymous. Granting elevated // principals needs an explicit membership check (user-id allowlist / users.info) — // the envelope teamId alone does not prove membership; see SlackIdentity. The result // is pinned on event.principal, flows to ToolContext.principal, and gates // Tool.requiresPrincipal tools. Fail-closed: a throwing resolver is reported to // onError and the turn runs anonymous. resolveIdentity?: (identity: SlackIdentity, ctx: ChannelContext) => Promise | Principal | null | undefined; // Render the turn LIVE: post a "Thinking…" message, then edit it in place as the turn's // events arrive (tool status, then the final answer) instead of posting once at the end. // Requires the host to supply ctx.runStream (the edge Durable Object and the native // mountAgent both do); falls back to post-once when it's absent. stream?: boolean; // The agent-era "typing indicator": while a turn runs, show this presence line (e.g. // "is thinking…") under the composer via assistant.threads.setStatus. Slack clears it // itself the moment the reply posts/streams (with a 2-minute timeout as backstop); a // tool-only turn that posts nothing clears it explicitly. Needs the app's Agents & AI // Apps feature — elsewhere the call fails harmlessly (best-effort), so it is always safe. status?: string; // Native 👍/👎 feedback buttons appended when the streamed reply finalizes: chat.stopStream // carries a context_actions block holding Slack's purpose-built feedback_buttons element. // A click arrives as a block_actions interaction and lands in onFeedback (background, // best-effort); the button value ties it back to {turnId, session}. Streaming path only — // the postMessage fallback (older app) skips the buttons. Requires stream: true. // Rendering caveat (observed 2026-07-15): the blocks attach to the message everywhere // (visible in the message JSON), but Slack clients may only RENDER them in agent // surfaces (the app DM) — plan the feedback UX around the DM experience. feedback?: boolean; onFeedback?: (feedback: SlackFeedback, ctx: ChannelContext) => void | Promise; // Attach Slack's native confirmation dialog to the HITL Approve/Deny buttons: clicking // pops a modal (title / prompt excerpt / Approve-or-Deny / Cancel; the Deny dialog is // styled danger) and the interaction only fires after the human confirms — fat-finger // protection for approval buttons in a busy channel. Off by default (one-click resume, // the prior behavior). approvalConfirm?: boolean; // Cancel-and-replace / debounce (#129): a NEW message or mention in a thread supersedes // the turn still running there — the stale turn is cancelled at its next checkpoint // boundary (its stream closes with a "superseded" note; a turn that hadn't posted yet // vanishes silently) and the new message runs instead, so the reply answers the LATEST // question, not an outdated one. Scoped to message/app_mention: a reaction-driven turn // never cancels an answer in progress. A parked approval is never cancelled (the engine // still rejects new turns while one is pending). Off by default: in a thread where // several people ask independent questions, replacing would eat each other's answers. replaceInFlight?: boolean; // The interaction escape hatch (#88): every signature-verified interaction payload the // built-in routing does NOT claim (june_feedback / june_input:* action_ids) is handed // here instead of silently dropped — the app's own buttons on posted messages route // through the SAME endpoint, no parallel webhook, no duplicated signature verification. // Runs in the background with the fast-ACK discipline (errors → onError). Includes // non-block_actions payload types (shortcuts, view submissions) — payload.type demuxes. onInteraction?: (payload: SlackInteraction, ctx: ChannelContext) => void | Promise; // Render tool calls as Slack's native task timeline INSIDE the streamed message: map a // tool call to a ≤256-char task title (return undefined/"" to hide that call). A call // shows as in_progress on action.requested, and complete on action.completed — or error // when the tool threw (the model was told and carries on; #232). NOTE: this // makes a tool-only turn post a message (the timeline IS content) — a deliberate departure // from the lazy-start rule, which is why it is opt-in. Requires stream: true. Task cards // render in regular channels too (verified live 2026-07-15 — a `task_card` block). tasks?: (call: { id: string; name: string; input: unknown }) => string | undefined; // How Slack lays the timeline out: sequential "timeline" (Slack's default), grouped // "plan", or "dense" (consecutive tool calls collapse into one card). Needs `tasks`. taskDisplayMode?: "timeline" | "plan" | "dense"; // What to do with text the model writes in a step that then calls a tool ("Let me // search the docs…") when streaming (#175). "answer" (default): it streams into the reply // like any text, so the preamble becomes part of the answer. "status": it renders as // progress instead — a task-timeline entry when `tasks` is on, else the status line under // the composer (assistant.threads.setStatus) — and only the final step's text is the // answer. Whether a step will call a tool is known only when it ends, and a Slack stream // can't take text back, so under "status" each step is held until it ends: the answer // appears whole rather than token by token. Requires stream: true. intermediateText?: "answer" | "status"; onError?: (err: unknown) => void; } & ChannelExtensions): Channel & { diagnose: () => Promise } { const api = opts.apiUrl ?? "https://slack.com/api"; // Derive the subscribe list from intent (respondTo + on keys) so kinds aren't written // twice and can't drift; explicit `events` overrides. When the app expresses no intent // at all, keep the friendly default (message + app_mention). const onKinds = Object.keys(opts.on ?? {}) as SlackEventKind[]; const events: SlackEventKind[] = opts.events ?? (opts.respondTo || opts.on ? [...new Set([...(opts.respondTo ?? []), ...onKinds])] : ["message", "app_mention"]); const respondTo: string[] = opts.mode === "observe" ? [] : (opts.respondTo ?? events); const respondsTo = (event: SlackNormalizedEvent, raw: unknown): boolean => { if (!opts.respondWhen) return true; try { return opts.respondWhen(event, raw); } catch (err) { try { opts.onError?.(err); } catch { /* error reporting failed — nothing more to do */ } return false; } }; const authHeaders = { "content-type": "application/json; charset=utf-8", authorization: `Bearer ${opts.botToken}` }; async function postMessage(channel: string, text: string, thread_ts?: string): Promise { const r = (await (await fetch(`${api}/chat.postMessage`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel, text, thread_ts }) })).json().catch(() => ({}))) as { ts?: string }; return r.ts; } // Slack's native streaming API (chat.startStream/appendStream/stopStream) — purpose-built // for token streaming into ONE message: appendStream handles bursts (unlike chat.update's // ~1/s whole-message replace). startStream returns the message ts to append/stop against. // seed the stream with the first token (markdown_text) — Slack's streaming API expects // content, and seeding saves the extra appendStream for that token. Slack's anchor rule // (live-verified 2026-07-15): a CHANNEL stream requires the recipient's user+team ids // even WITH thread_ts (missing_recipient_team_id otherwise) — the official SDK's streamer // passes channel + thread_ts + recipient_user_id + recipient_team_id together. // Content is markdown_text OR a chunks array (task_update / plan_update / blocks) — the // task timeline rides the same three calls as the text. type StreamContent = { markdown_text?: string; chunks?: Record[] }; async function startStream(channel: string, thread_ts: string | undefined, content: StreamContent, recipient?: { user?: string; team?: string }): Promise { const body = { channel, thread_ts, ...content, task_display_mode: opts.tasks ? opts.taskDisplayMode : undefined, recipient_user_id: recipient?.user, recipient_team_id: recipient?.team }; const r = (await (await fetch(`${api}/chat.startStream`, { method: "POST", headers: authHeaders, body: JSON.stringify(body) })).json().catch(() => ({}))) as { ts?: string }; return r.ts; } // One append, with a single honored-Retry-After retry on `ratelimited` (Tier 4 allows // bursts; two 429s in a row means back off for real — report, don't spin). The error goes // back to the renderer: `stopped_by_user` (the human hit Stop) must end rendering, and any // other failure must be LOUD — an ignored append silently truncates the message. (The Stop // affordance only renders in Slack's agent surfaces — the handling is defensive either way.) async function appendStream(channel: string, ts: string, content: StreamContent): Promise<{ ok: boolean; error?: string }> { for (let attempt = 0; ; attempt++) { const res = await fetch(`${api}/chat.appendStream`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel, ts, ...content }) }); const body = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }; if (body.ok) return { ok: true }; if (body.error === "ratelimited" && attempt === 0) { await new Promise((r) => setTimeout(r, Math.min(Number(res.headers.get("retry-after") ?? 1) || 0, 10) * 1000)); continue; } return { ok: false, error: body.error }; } } async function stopStream(channel: string, ts: string, blocks?: unknown[]) { await fetch(`${api}/chat.stopStream`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel, ts, blocks }) }); } // The "is thinking…" presence line under the composer (assistant.threads.setStatus). Slack // clears it itself when the app posts/streams into the thread (2-minute timeout otherwise); // an empty status clears it explicitly. Best-effort: outside an AI-app assistant thread the // call just fails, and that must never break the turn. async function setStatus(channel_id: string, thread_ts: string, status: string) { await fetch(`${api}/assistant.threads.setStatus`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel_id, thread_ts, status }) }).catch(() => {}); } async function updateMessage(channel: string, ts: string, text: string) { await fetch(`${api}/chat.update`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel, ts, text, blocks: [] }) }); } // Post the HITL prompt as a message with Approve / Deny buttons. Each button's value carries // the {turnId, inputId, input, session} the interaction handler needs to route session.resume; // the action_id prefix `june_input` is how we recognize our own buttons. `session` matters for // a PROACTIVE turn: its session is caller-chosen, not the inbound slack:: // convention, so the click couldn't re-derive it from the message's location. async function postApproval(channel: string, thread_ts: string | undefined, turnId: string, request: InputRequest, session?: string): Promise { // Native confirmation dialog (opt-in via approvalConfirm): Slack pops a modal on click // and only sends the interaction after the human confirms — fat-finger protection for // approval buttons sitting in a busy channel. Field limits per the confirmation-dialog // object: title ≤100, text ≤300, confirm/deny labels ≤30. const confirmFor = (approving: boolean) => opts.approvalConfirm ? { confirm: { title: { type: "plain_text", text: approving ? "Confirm approval" : "Confirm denial" }, text: { type: "plain_text", text: request.prompt.slice(0, 300) }, confirm: { type: "plain_text", text: approving ? "Approve" : "Deny" }, deny: { type: "plain_text", text: "Cancel" }, ...(approving ? {} : { style: "danger" }), }, } : {}; const btn = (text: string, input: boolean, style: "primary" | "danger") => ({ type: "button", text: { type: "plain_text", text }, style, action_id: `june_input:${input ? "yes" : "no"}`, value: JSON.stringify({ turnId, inputId: request.id, input, session }), ...confirmFor(input), }); const blocks = [ { type: "section", text: { type: "mrkdwn", text: request.prompt } }, { type: "actions", elements: [btn("Approve", true, "primary"), btn("Deny", false, "danger")] }, ]; const r = (await (await fetch(`${api}/chat.postMessage`, { method: "POST", headers: authHeaders, body: JSON.stringify({ channel, thread_ts, text: request.prompt, blocks }) })).json().catch(() => ({}))) as { ok?: boolean; ts?: string }; // The turn is parked either way — if the prompt didn't post, nobody has buttons to resume // it, and that must be loud (onError), not a silently bricked approval. if (!r.ok) opts.onError?.(new Error(`slack: failed to post the HITL prompt for input "${request.id}" (turn ${turnId})`)); return r.ts; } // Slack caps one markdown_text at 12k chars; longer content is sliced across appends. const MD_MAX = 12000; // Coalesce token deltas: appendStream is Tier 4 (~100/min + bursts) but a model emits many // deltas per second — buffer and flush at most ~2/s (or when a slice fills), so a long turn // can't spend the tier while the first token still seeds the stream IMMEDIATELY. const FLUSH_MS = 500; // Render a turn LIVE via Slack's streaming API: startStream → appendStream each answer-token // delta → stopStream. The Slack stream is started LAZILY — only on the first piece of content // (a delta, the final one-shot reply, or a failure note) — so a tool-only / empty / no-output // turn posts NOTHING (no empty streamed message). When startStream is unavailable (older app), // content accumulates and is posted once via chat.postMessage — including a failure note. async function streamRender(surface: DeliveryTarget, events: AsyncIterable, session?: string, files?: FileStore) { const { channelId, threadId } = surface; const attached: FileRef[] = []; // file.attached refs, uploaded once the reply text is out let streamTs: string | undefined; let started = false; // startStream attempted (regardless of success) let stoppedByUser = false; // the human hit Slack's Stop — the message is final, cease rendering let broken = false; // an append failed hard — keep the tail for a postMessage salvage at finish let pending = ""; // deltas awaiting a flush (doubles as the postMessage-fallback accumulator) let lastFlush = 0; let turnId: string | undefined; // stamped from the first event — the feedback buttons carry it if (opts.status && threadId) await setStatus(channelId, threadId, opts.status); // Whether a status line may be showing — the typing indicator above, or a progress line // (intermediateText "status", #175) set even when `status` is off. Every path that ends // without posting clears it on this, not on opts.status. let statusShown = !!(opts.status && threadId); // Slack's recipient rule cuts BOTH ways (live-verified 2026-07-15): a channel stream // REQUIRES recipient_user_id/recipient_team_id (missing_recipient_team_id), while a DM // stream REJECTS them (invalid_arguments) — branch on the id's D-prefix (im channels). const recipient = channelId.startsWith("D") ? undefined : { user: surface.recipientUserId, team: surface.recipientTeamId }; // A stream is EITHER markdown_text-mode or chunks-mode, fixed at startStream — mixing // raw markdown_text into a chunks-opened stream is streaming_mode_mismatch (live-verified // 2026-07-15). With tasks on, a task chunk may arrive before OR after the first token, // so the whole stream runs in chunks mode: text rides as {type:"markdown_text"} chunks. const asContent = (text: string): StreamContent => (opts.tasks ? { chunks: [{ type: "markdown_text", text }] } : { markdown_text: text }); const flush = async () => { if (!pending || stoppedByUser || broken) return; if (!started) { started = true; lastFlush = Date.now(); const seed = pending.slice(0, MD_MAX); // seed with the first token(s) — the API expects content streamTs = await startStream(channelId, threadId, asContent(seed), recipient); if (!streamTs) return; // unavailable → pending rides whole into the postMessage fallback pending = pending.slice(seed.length); } if (!streamTs) return; while (pending && !stoppedByUser && !broken) { const slice = pending.slice(0, MD_MAX); const r = await appendStream(channelId, streamTs, asContent(slice)); if (r.ok) { pending = pending.slice(slice.length); continue; } if (r.error === "stopped_by_user") { stoppedByUser = true; pending = ""; return; } // discard: the human ended it broken = true; // `pending` keeps the unsent tail — finish() posts it, nothing silently truncates opts.onError?.(new Error(`slack: chat.appendStream failed (${r.error ?? "no response"}) — posting the tail via chat.postMessage`)); } lastFlush = Date.now(); }; const push = async (t: string) => { if (!t || stoppedByUser) return; pending += t; if (started && (!streamTs || broken)) return; // fallback/salvage mode: accumulate only, finish() posts it if (!started || pending.length >= MD_MAX || Date.now() - lastFlush >= FLUSH_MS) await flush(); }; // A task chunk must land in ORDER relative to the text, so buffered text flushes first. // A failed chunk is decorative loss: report and carry on (if the stream is actually dead // the next text flush notices and salvages); stopped_by_user still ends rendering. The // timeline never rides the postMessage fallback — that path is text-only. const pushChunk = async (chunk: Record) => { if (stoppedByUser) return; await flush(); if (broken) return; if (!started) { started = true; lastFlush = Date.now(); streamTs = await startStream(channelId, threadId, { chunks: [chunk] }, recipient); // a chunk can open the stream return; } if (!streamTs) return; const r = await appendStream(channelId, streamTs, { chunks: [chunk] }); if (r.error === "stopped_by_user") { stoppedByUser = true; pending = ""; } else if (!r.ok) opts.onError?.(new Error(`slack: task chunk append failed (${r.error ?? "no response"})`)); }; // Returns whether anything actually POSTED — `started` alone can't tell: a task chunk // may have tried to open a stream that startStream couldn't (no ts), leaving a turn // that rendered nothing. The caller keys the status-clear on this, not on `started`. const finish = async (): Promise => { await flush(); if (stoppedByUser) return true; // a stream existed (the human stopped it) — content posted if (streamTs && !broken) await stopStream(channelId, streamTs, opts.feedback ? feedbackBlocks(turnId, session) : undefined); if (pending.trim() && (!streamTs || broken)) { await postMessage(channelId, pending, threadId); return true; } return !!streamTs; }; // intermediateText "status" (#175): the current step's text, held until the step ends — // progress if it ends in a tool call, the answer (via turn.completed) if it doesn't. const holdSteps = opts.intermediateText === "status"; let stepText = ""; let notes = 0; const showProgress = async (text: string) => { const line = text.trim().slice(0, 256); if (!line) return; if (opts.tasks) await pushChunk({ type: "task_update", id: `june_note:${++notes}`, title: line, status: "complete" }); else if (threadId) { await setStatus(channelId, threadId, line); statusShown = true; } }; try { let streamed = false; let finalText = ""; for await (const e of events) { if (stoppedByUser) return; // stop rendering; the turn itself runs on host-side turnId ??= e.turnId; if (e.type === "message.delta") { if (holdSteps) stepText += e.text; else { await push(e.text); streamed = true; } continue; } // The step ended in a tool call: what it said was progress, not answer. Only the // batch's first call carries it — the text is consumed here. if (e.type === "action.requested" && holdSteps && stepText) { await showProgress(stepText); stepText = ""; } if ((e.type === "action.requested" || e.type === "action.completed") && opts.tasks) { // a tool call becomes a native task-timeline entry: in_progress when requested, // complete when done. The app's mapper names it (or hides it with undefined/""). const title = opts.tasks(e.call); const status = e.type === "action.requested" ? "in_progress" : e.error !== undefined ? "error" : "complete"; if (title) await pushChunk({ type: "task_update", id: e.call.id, title: title.slice(0, 256), status }); } else if (e.type === "turn.completed") finalText = e.text; else if (e.type === "file.attached") collectFile(attached, e.file); else if (e.type === "turn.failed") { await push("\n_(the turn failed)_"); await finish(); return; } else if (e.type === "turn.cancelled") { // Cancelled (#129): close what already streamed with a note so the half-answer // isn't mistaken for a full one — "superseded" only when a newer message really // replaced it (e.reason), neutral copy otherwise (an explicit cancel, a session // reset). A turn that hadn't posted yet vanishes silently. The status line is // deliberately NOT touched: under replace the SUCCESSOR turn already owns it and // clearing from here would wipe the newer turn's indicator (races the setStatus // it issued at entry); Slack's 2-minute timeout backstops the no-successor case. if (started) { await push(e.reason === "replaced" ? "\n_(superseded by a newer message)_" : "\n_(cancelled)_"); await finish(); } return; } else if (e.type === "input.requested") { // the turn parked awaiting a human: finalize any streamed text, then post the prompt // with Approve/Deny buttons. The interaction handler (below) routes the click to resume. if (started) await finish(); await deliverFiles(files, channelId, threadId, attached); // the work so far, above the prompt const promptTs = await postApproval(channelId, threadId, e.turnId, e.request, session); // the prompt normally auto-clears the status; if it failed to post (reported via // onError, not thrown), clear explicitly — nothing else ever will if (!promptTs && statusShown && threadId) await setStatus(channelId, threadId, ""); return; // the stream closed on suspend; the turn continues on the button click } } if (!streamed && finalText.trim()) await push(finalText); // one-shot: the whole reply once const posted = started ? await finish() : false; // nothing pushed (tool-only/empty) ⇒ never started ⇒ post nothing const uploaded = await deliverFiles(files, channelId, threadId, attached); // when NOTHING actually posted — never started, or a task chunk tried to start a // stream that startStream couldn't open — nothing will ever auto-clear the status; // clear it now instead of leaving "is thinking…" to Slack's 2-minute timeout. if (!posted && !uploaded && statusShown && threadId) await setStatus(channelId, threadId, ""); } catch (err) { await push("\n_(the turn failed)_").catch(() => {}); // starts the stream/buffer if not yet await finish().catch(() => {}); // the failure note normally auto-clears the status — but if even the salvage failed, // don't leave "is thinking…" stuck until Slack's 2-minute timeout if (statusShown && threadId) await setStatus(channelId, threadId, "").catch(() => {}); throw err; // let runBackground → onError record it } } // The inbound reply path: render the turn started FROM an event, to that event's own thread. // The event's author + workspace become the stream's recipient — chat.startStream requires // recipient_user_id/recipient_team_id for CHANNEL streams even in-thread (live-verified // 2026-07-15: missing_recipient_team_id), and "who asked" is exactly what they mean. function renderStream(ctx: ChannelContext, event: InboundEvent, userText: string, session: string, replace?: boolean) { return streamRender( { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId }, ctx.runStream!(userText, { session, event, replace }), session, ctx.files, ); } // Post-once render that still understands HITL. The plain ctx.run path collapses the turn // to its final text — but a parked turn (input.requested) HAS no final text, so the collapse // errors and the Approve/Deny prompt would never post. When the host provides the event // stream, consume it non-live instead: post the final text once, post the approval prompt // when the turn parks. Failure semantics match ctx.run (throw → runBackground → onError). async function renderOnce(ctx: ChannelContext, event: InboundEvent, userText: string, session: string, replace?: boolean) { if (opts.status && event.threadId) await setStatus(event.channelId, event.threadId, opts.status); try { let finalText = ""; const attached: FileRef[] = []; for await (const e of ctx.runStream!(userText, { session, event, replace })) { if (e.type === "turn.completed") finalText = e.text; else if (e.type === "file.attached") collectFile(attached, e.file); else if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along else if (e.type === "turn.cancelled") { // Cancelled (#129): this turn's reply will never come. Post nothing, and leave // the status line alone — under replace the successor turn owns it (clearing // here would wipe the newer turn's indicator); Slack's 2-minute timeout // backstops a cancellation with no successor. return; } else if (e.type === "input.requested") { await deliverFiles(ctx.files, event.channelId, event.threadId, attached); const promptTs = await postApproval(event.channelId, event.threadId, e.turnId, e.request, session); // a failed prompt post (reported, not thrown) leaves nothing to auto-clear the status if (!promptTs && opts.status && event.threadId) await setStatus(event.channelId, event.threadId, ""); return; } } if (finalText.trim()) await postMessage(event.channelId, finalText, event.threadId); const uploaded = await deliverFiles(ctx.files, event.channelId, event.threadId, attached); if (!finalText.trim() && !uploaded && opts.status && event.threadId) await setStatus(event.channelId, event.threadId, ""); // tool-only: nothing posts to auto-clear } catch (err) { // a failed turn posts nothing — clear the status instead of leaving "is thinking…" // stuck until Slack's 2-minute timeout if (opts.status && event.threadId) await setStatus(event.channelId, event.threadId, "").catch(() => {}); throw err; } } // Route an Approve/Deny click to session.resume and render the continuation into the button // message. The clicker's id is the VERIFIED resumer (`by`) — the signature was checked above, // and Slack's payload.user.id is trustworthy; the engine enforces it against the request's // answerers (a { user } must match it; a { policy } is decided by the host's grantAnswer). // Returns whether a built-in branch CLAIMED the payload (#88): june_feedback and // june_input:* action_ids are ours (claimed even when dropped — e.g. onFeedback absent); // everything else is unrouted and falls through to onInteraction at the call site. function handleInteraction(payload: SlackInteraction, ctx: ChannelContext): boolean { if (payload.type !== "block_actions") return false; const action = payload.actions?.[0]; // a feedback_buttons click (minted by stopStream): normalize and hand to onFeedback. // Nothing to resume and no reply expected — pure telemetry, background + best-effort. if (action?.action_id === "june_feedback" && action.value) { const fb = tryParseJson<{ rating?: string; turnId?: string; session?: string }>(action.value); // validate, don't just truthy-check: the declared contract is "positive" | "negative" const rating = fb?.rating === "positive" || fb?.rating === "negative" ? fb.rating : undefined; if (fb && rating && opts.onFeedback) { runBackground(ctx, async () => opts.onFeedback!({ rating, turnId: fb.turnId, session: fb.session, user: payload.user?.id ? { id: payload.user.id } : undefined, channelId: payload.channel?.id, threadId: payload.message?.thread_ts ?? payload.message?.ts, messageTs: payload.message?.ts, }, ctx), opts.onError); } return true; } if (!action?.action_id?.startsWith("june_input:")) return false; // not our button (action_ids are june_input:yes|no) // From here the click IS ours — CLAIMED even when unusable (a framework action_id // must never leak to onInteraction), and a dead end must be loud (onError), never // a silent no-op. A missing value folds into the unusable-payload branch below. const parsed = action.value ? tryParseJson<{ turnId: string; inputId: string; input: unknown; session?: string }>(action.value) : undefined; const channel = payload.channel?.id, msgTs = payload.message?.ts; const thread = payload.message?.thread_ts ?? msgTs; if ((!ctx.resumeDelivered && !ctx.resumeStream) || !parsed || !channel || !thread || !msgTs) { opts.onError?.(new Error(ctx.resumeDelivered || ctx.resumeStream ? "slack: HITL click with an unusable payload (value/channel/message missing)" : "slack: HITL click but the host provides no resumeStream")); return true; // ours (june_input) even when unusable — never handed to onInteraction } // The button value names the parked turn's session (a PROACTIVE session is caller-chosen); // fall back to the inbound convention for buttons posted before the value carried it. const session = parsed.session ?? `slack:${channel}:${thread}`; runBackground(ctx, async () => { const target: ResumeDeliveryTarget = { channelId: channel, threadId: thread, messageTs: msgTs }; // DELIVERED resume first: the turn's host applies the answer and renders the // continuation through this channel's own deliverResume() under its OWN lifetime — // a long continuation no longer dies with this isolate's post-ACK grace (which left // the prompt stuck on "_Working…_" forever). An engine rejection (unauthorized // clicker, stale/double click) surfaces as a throw exactly like resumeStream's // first pull, and gets the same ephemeral treatment; only the typed pre-effect // refusal falls back — anything else may already have applied the answer, and // re-resuming would double-answer (the engine would 409 the second apply). if (ctx.resumeDelivered) { try { await ctx.resumeDelivered({ session, turnId: parsed.turnId, inputId: parsed.inputId, input: parsed.input, by: payload.user?.id, source: "slack", target }); return; } catch (err) { if (!(err instanceof DeliverUnsupportedError)) { await postEphemeralRejection(payload); throw err; // still record via onError } // the host can't deliver (channel not wired where the turn runs) and the answer // was NOT applied — resume from here instead } } if (!ctx.resumeStream) { opts.onError?.(new Error("slack: HITL click but the host provides no resumeStream")); return; } // Pull the FIRST event before touching the message: the engine rejects an unauthorized // clicker (403) or a stale/double click (409) on that first pull, and a rejection must // leave the Approve/Deny buttons intact for the rightful answerer. const it = ctx.resumeStream({ session, turnId: parsed.turnId, inputId: parsed.inputId, input: parsed.input, by: payload.user?.id })[Symbol.asyncIterator](); let first: IteratorResult; try { first = await it.next(); } catch (err) { await postEphemeralRejection(payload); throw err; // still record via onError } try { await resumeRender(target, prependResult(first, it), session, ctx.files); } finally { // Close the manual iterator on every exit so an SSE-backed resumeStream isn't left // open (resumeRender's for-await only closes iterators IT created). await it.return?.(); } }, opts.onError); return true; } // Tell only the clicker why nothing happened (response_url posts ephemerally); a // rejection leaves the Approve/Deny buttons intact for the rightful answerer. async function postEphemeralRejection(payload: SlackInteraction) { if (!payload.response_url) return; await fetch(payload.response_url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response_type: "ephemeral", replace_original: false, text: "This request can't be resumed by you — it may already be answered, or you're not the designated approver." }), }).catch(() => {}); } // Re-compose a manually-pulled first result with the rest of its iterator so the shared // renderer consumes one plain AsyncIterable (the worker-side fallback pulls the first // event by hand — an engine rejection must surface BEFORE the buttons are touched). async function* prependResult(first: IteratorResult, it: AsyncIterator): AsyncIterable { for (let r = first; !r.done; r = await it.next()) yield r.value; } // Render a resumed turn's continuation into the Approve/Deny prompt message: drop the // buttons for progress, then the outcome (final text / failure note / the next approval // on a re-park). ONE renderer shared by the worker-side fallback and the channel's // deliverResume() (invoked by the turn's host on a delivered resume), so both isolates // render identically. async function resumeRender(target: ResumeDeliveryTarget, events: AsyncIterable, session?: string, files?: FileStore) { const { channelId, threadId, messageTs } = target; try { await updateMessage(channelId, messageTs, "_Working…_"); // resume accepted: drop the buttons; show progress let final = ""; const attached: FileRef[] = []; for await (const ev of events) { if (ev.type === "turn.completed") final = ev.text; else if (ev.type === "file.attached") collectFile(attached, ev.file); else if (ev.type === "turn.failed") { await updateMessage(channelId, messageTs, "_(the turn failed)_"); return; } else if (ev.type === "input.requested") { await deliverFiles(files, channelId, threadId, attached); await postApproval(channelId, threadId, ev.turnId, ev.request, session); await updateMessage(channelId, messageTs, "_(awaiting more input…)_"); return; } } await updateMessage(channelId, messageTs, final.trim() || "_(done)_"); await deliverFiles(files, channelId, threadId, attached); } catch (err) { // the continuation stream dropped mid-flight (the resume itself was accepted) await updateMessage(channelId, messageTs, "_(the turn failed)_").catch(() => {}); throw err; } } // Files the turn attached (docs/rfc-agent-files.md), uploaded into the reply's thread after // the text that describes them. Best-effort like the rest of the reply path: the turn has // already completed, so a file that can't be read or uploaded is reported via onError and // noted in the thread — never thrown. Returns whether anything posted (the status line // auto-clears on a post). async function deliverFiles(files: FileStore | undefined, channel: string, thread_ts: string | undefined, refs: FileRef[]): Promise { if (!refs.length) return false; if (!files) { // The reply may already say "attached": tell the thread, not only the logs. Happens when // the turn's host stores files (the Durable Object) but this renderer's host can't read // them (a worker-side surface without `files`). opts.onError?.(new Error(`slack: ${refs.length} attached file(s) not delivered — the host gave the channel no file store`)); await postMessage(channel, `_(couldn't attach ${refs.map((r) => escapeMrkdwn(r.name)).join(", ")})_`, thread_ts).catch(() => {}); return true; } for (const ref of refs) { try { const data = await files.get(ref.id); if (!data) throw new Error(`the file store does not hold ${ref.id}`); await uploadFile(channel, thread_ts, ref, data); } catch (err) { opts.onError?.(new Error(`slack: couldn't attach ${ref.name}: ${err instanceof Error ? err.message : String(err)}`, { cause: err })); await postMessage(channel, `_(couldn't attach ${escapeMrkdwn(ref.name)})_`, thread_ts).catch(() => {}); } } return true; } // Slack's external upload flow (files.upload is retired): reserve an upload URL, POST the // bytes to it, then complete the upload into the channel/thread. Both Web API calls take // form-encoded arguments; `files` rides as a JSON string. async function uploadFile(channel: string, thread_ts: string | undefined, ref: FileRef, data: Uint8Array) { const form = (params: Record): RequestInit => ({ method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Bearer ${opts.botToken}` }, body: new URLSearchParams(params).toString(), }); const scopeHint = (error?: string) => (error === "missing_scope" ? " — add the files:write scope and reinstall the app" : ""); const reserved = (await (await fetch(`${api}/files.getUploadURLExternal`, form({ filename: ref.name, length: String(data.byteLength) }))).json().catch(() => ({}))) as { ok?: boolean; error?: string; upload_url?: string; file_id?: string }; if (!reserved.ok || !reserved.upload_url || !reserved.file_id) throw new Error(`files.getUploadURLExternal failed (${reserved.error ?? "no response"})${scopeHint(reserved.error)}`); const sent = await fetch(reserved.upload_url, { method: "POST", headers: { "content-type": ref.mediaType }, body: data.slice() }); if (!sent.ok) throw new Error(`the upload URL answered HTTP ${sent.status}`); const done = (await (await fetch(`${api}/files.completeUploadExternal`, form({ files: JSON.stringify([{ id: reserved.file_id, title: ref.name }]), channel_id: channel, ...(thread_ts ? { thread_ts } : {}) }))).json().catch(() => ({}))) as { ok?: boolean; error?: string }; if (!done.ok) throw new Error(`files.completeUploadExternal failed (${done.error ?? "no response"})${scopeHint(done.error)}`); } // Slack Web API read helper: GET with query params + bearer token. Read methods // (conversations.replies, reactions.get, users.info) all accept this shape. Returns // the parsed JSON — callers check `ok`/`error` per Slack's envelope. async function slackGet(method: string, params: Record): Promise { const qs = new URLSearchParams(params).toString(); const res = await fetch(`${api}/${method}?${qs}`, { headers: { authorization: `Bearer ${opts.botToken}` } }); return (await res.json()) as SlackResponse; } // Slack Web API write helper: POST JSON + bearer token (reactions.add, …). async function slackPost(method: string, params: Record): Promise { const res = await fetch(`${api}/${method}`, { method: "POST", headers: { "content-type": "application/json; charset=utf-8", authorization: `Bearer ${opts.botToken}` }, body: JSON.stringify(params), }); return (await res.json()) as SlackResponse; } const valid = (ts: string, body: string, sig: string) => verifySlackSignature(opts.signingSecret, ts, body, sig); // #90: per-isolate delivery counters feeding diagnose(). Keyed by MOUNT PATH in a // module-level registry, not held on the channel instance: the standard edge mount // rebuilds the surface (and re-runs the channel factory) on every request, so // instance state would reset per delivery and an admin route constructing its own // channel to call diagnose() would always read zero. Module state IS isolate state — // "per isolate" by construction, shared by every same-path construction within it. const counters = slackCountersFor(opts.path ?? "/channels/slack"); const turnAway = (req: Request, rejection: ChannelRejection) => { counters.rejections[rejection.kind]++; reject(opts, req, rejection); }; return { name: "slack", path: opts.path ?? "/channels/slack", tools: () => slackTools(slackGet, slackPost), // Proactive delivery (§9): render an agent-initiated turn's stream to a target thread, // the same renderer as an inbound reply — progressive edits, HITL prompts, final text. // `opts.session` routes an HITL prompt's resume back to the caller-chosen session. deliver: (target, events, o) => streamRender(target, events, o?.session, o?.files), // Resumed-continuation renderer, host-invokable (delivered resume): same function the // worker-side fallback uses, so rendering is identical wherever the continuation runs. deliverResume: (target, events, o) => resumeRender(target, events, o?.session, o?.files), // Deterministic outbound post (#89): one app-authored message over the channel's own // auth/transport, returning its (channel, ts) identity. Throws loudly on a platform // error — unlike the best-effort reply path, the app initiated this and must know. async post(target, content) { const text = typeof content === "string" ? content : content.text ?? ""; const blocks = typeof content === "string" ? undefined : content.blocks; // fail closed on `note`: Slack has no private-note concept, and downgrading an // operator-only note to a public message would leak it to the end user. A // non-boolean value is a confused caller — same loud rejection (crisp's dual). const note = typeof content === "string" ? undefined : content.note; if (note !== undefined && typeof note !== "boolean") throw new Error(`slack: post note must be a boolean (got ${JSON.stringify(note)})`); if (note) throw new Error("slack: note posts are not supported (private notes are a Crisp concept)"); // fail fast, client-side: an empty post would round-trip to Slack's no_text error if (!text.trim() && !blocks?.length) throw new Error("slack: post needs text or non-empty blocks"); const payload: Record = { channel: target.channelId, thread_ts: target.threadId, text }; if (blocks) payload.blocks = blocks; const r = (await (await fetch(`${api}/chat.postMessage`, { method: "POST", headers: authHeaders, body: JSON.stringify(payload) })) .json().catch(() => ({}))) as { ok?: boolean; ts?: string; channel?: string; error?: string }; if (!r.ok || !r.ts) throw new Error(`slack: chat.postMessage failed (${r.error ?? "no response"})`); return { channelId: r.channel ?? target.channelId, threadId: target.threadId, ts: r.ts }; }, // Preflight diagnostics (#90): one call answering the silent-failure hunt. Verifies // the token (auth.test), compares the app's GRANTED scopes (the x-oauth-scopes // response header) against what the ENABLED features need, and reports the // per-kind delivery counters — "app_mention received: 0" is the one-line answer // to "Socket Mode is on / the Request URL points elsewhere". Read-only and safe // to expose behind an admin route or run from a REPL. async diagnose(): Promise { const res = await fetch(`${api}/auth.test`, { method: "POST", headers: authHeaders }).catch(() => undefined); const granted = (res?.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter(Boolean); const j = res ? ((await res.json().catch(() => ({}))) as { ok?: boolean; error?: string; team?: string; user_id?: string }) : {}; const auth: SlackDiagnosis["auth"] = { ok: j.ok === true, error: j.error, team: j.team, botUserId: j.user_id }; // What the ENABLED features need, merged per scope so one missing scope reads once. const needs = new Map(); const need = (scope: string, forWhat: string) => needs.set(scope, [...(needs.get(scope) ?? []), forWhat]); need("chat:write", "posting replies (chat.postMessage)"); if (opts.stream) need("assistant:write", "streamed replies (chat.startStream)"); if (opts.status) need("assistant:write", "the status line (assistant.threads.setStatus)"); if (events.includes("message")) need("channels:history", 'the "message" event subscription'); if (events.includes("app_mention")) need("app_mentions:read", 'the "app_mention" event subscription'); if (events.includes("reaction_added") || events.includes("reaction_removed")) need("reactions:read", "reaction events"); // The capability tools are ALWAYS merged into agent.tools — a green diagnose must // mean the model can actually run them, not just that the webhook paths work. need("channels:history", "the slack_read_thread tool"); need("reactions:read", "the slack_list_reactions tool"); need("users:read", "the slack_resolve_user tool"); need("reactions:write", "the slack_add_reaction tool"); // Like the tools: uploads run whenever a tool attaches a file, which the channel can't // know in advance, so a green diagnose must cover them (docs/rfc-agent-files.md §3). need("files:write", "uploading files a turn attaches (files.getUploadURLExternal)"); const missing = auth.ok ? [...needs].filter(([scope]) => !granted.includes(scope)).map(([scope, why]) => ({ scope, neededFor: why.join("; ") })) : []; const hints: string[] = []; if (!auth.ok) hints.push(`auth.test failed (${j.error ?? "no response"}) — is the bot token valid for this workspace?`); for (const m of missing) hints.push(`missing scope "${m.scope}" — needed for ${m.neededFor}; add it and reinstall the app`); for (const kind of events) { if (!counters.eventsReceived[kind]) { hints.push(`${kind} received: 0 since this isolate started — if you have sent one, check Socket Mode is OFF and the Events Request URL points at this deployment`); } } if (counters.interactions.unrouted > 0) { hints.push(`${counters.interactions.unrouted} verified interaction(s) arrived that nothing claimed — check the Interactivity Request URL, or add onInteraction`); } if (counters.rejections.bad_signature > 0) { hints.push(`${counters.rejections.bad_signature} delivery(ies) failed signature verification — signing secret mismatch between Slack and this deployment?`); } if (counters.duplicates > 0) { // Only ACKed deliveries are recorded, so each duplicate is one this deployment answered // 200 that Slack still didn't see succeed within 3 s — the ACK was slow, or the response // was lost or replaced on the way back. hints.push(`${counters.duplicates} Slack redelivery(ies) dropped as duplicates — this deployment ACKed them, but Slack didn't get a 2xx within 3 s: check ACK latency (cold start, a blocked event loop) and the path back to Slack (a proxy or load balancer returning errors or timing out, connection/TLS failures)`); } return { auth, scopes: { granted, missing }, counters: { ...counters, eventsReceived: { ...counters.eventsReceived }, interactions: { ...counters.interactions }, rejections: { ...counters.rejections } }, hints, }; }, async webhook(req, ctx) { const body = await req.text(); const ok = await valid( req.headers.get("x-slack-request-timestamp") ?? "", body, req.headers.get("x-slack-signature") ?? "", ); if (!ok) { turnAway(req, { kind: "bad_signature" }); return new Response("bad signature", { status: 401 }); } // Slack interactivity (a block_actions click on our Approve/Deny buttons) arrives // form-encoded as `payload=` — not an Events API JSON body. Route it to resume; // anything the built-in branches don't claim goes to onInteraction (#88) so the app's // own buttons ride this endpoint instead of a parallel webhook. if (body.startsWith("payload=")) { const interaction = tryParseJson(new URLSearchParams(body).get("payload") ?? ""); if (!interaction) turnAway(req, { kind: "malformed_body", detail: "interaction payload" }); else if (handleInteraction(interaction, ctx)) counters.interactions.claimed++; else if (opts.onInteraction) { counters.interactions.appHandled++; // delivered to the app — NOT a routing failure runBackground(ctx, async () => opts.onInteraction!(interaction, ctx), opts.onError); } else { counters.interactions.unrouted++; // detail: the action_id for a click; the payload type for actions-less // payloads (a shortcut, a view submission) so the rejection stays actionable turnAway(req, { kind: "unrouted_interaction", detail: interaction.actions?.[0]?.action_id ?? interaction.type }); } return new Response("", { status: 200 }); // fast ACK } const payload = tryParseJson<{ type?: string; challenge?: string; event?: SlackEvent; team_id?: string; event_id?: string }>(body); if (!payload) { turnAway(req, { kind: "malformed_body" }); return new Response("", { status: 200 }); } // signed but unparseable → ACK, don't retry if (payload.type === "url_verification") return Response.json({ challenge: payload.challenge }); if (payload.type === "event_callback") { // A redelivery of an event already handled here (#170): ACK and drop it before // anything runs — observers included, so nothing fires twice for one event. const seenPath = opts.path ?? "/channels/slack"; if (payload.event_id && slackSeen(seenPath, payload.event_id)) { counters.duplicates++; return new Response("", { status: 200 }); } // Recorded only on the way to the ACK: if anything below throws first (an app's // accept() callback, say), the delivery gets an error response, Slack retries it, // and the retry must be handled — not dropped as a duplicate of an event that never // ran. Check and record stay atomic: nothing between them awaits. const handled = () => { if (payload.event_id) slackRemember(seenPath, payload.event_id); }; // Count ARRIVAL first, before the accept gate and normalization: "arriving but // filtered (accept/loop-guard/kind)" and "never arriving" must read differently // in diagnose(), and a verified delivery has arrived regardless of what the app // then does with it. Keyed by normalized kind when one exists, else raw type. const preNorm = normalizeSlackEvent(payload.event ?? {}, events, opts.botUserId, payload.team_id); const countKey = preNorm?.event.kind ?? payload.event?.type ?? "unknown"; counters.eventsReceived[countKey] = (counters.eventsReceived[countKey] ?? 0) + 1; if (opts.accept && !opts.accept(payload)) { handled(); return new Response("", { status: 200 }); } // gated out const norm = preNorm; // identity: when the seam is configured, resolve WHO IS SPEAKING once per // normalized event and pin it on event.principal BEFORE any consumer runs — // observers and the respond path all await this shared promise, so an // observe-mode consumer sees the same principal the turn does (the crisp // channel's exact pattern). No evidence fetch here: the identity facts come // from the signature-verified payload itself (see SlackIdentity) — including // the sender's home-workspace id when Slack put one on the raw event, which // is what lets a resolver tell a workspace member from a Slack Connect // external participant arriving under the same envelope. Never rejects: a // throwing resolver is reported to onError, the event stays anonymous, and // requiresPrincipal tools simply stay hidden for the turn. const rawEv = (payload.event ?? {}) as { user_team?: string; source_team?: string; team?: string }; const identityReady: Promise = norm && opts.resolveIdentity ? (async () => { try { const principal = await opts.resolveIdentity!({ userId: norm.event.user?.id, teamId: norm.event.teamId, senderTeamId: rawEv.user_team ?? rawEv.source_team ?? rawEv.team, channelId: norm.event.channelId, threadId: norm.event.threadId, kind: norm.event.kind as SlackEventKind, ts: norm.event.ts, }, ctx); if (principal != null) norm.event.principal = principal; } catch (err) { try { opts.onError?.(err); } catch { /* error reporting failed — nothing more to do */ } } })() : Promise.resolve(); // observe: mirror EVERY verified event_callback (raw always; normalized when available) if (opts.onEvent) runBackground(ctx, async () => { await identityReady; return opts.onEvent!({ raw: payload, event: norm?.event }, ctx); }, opts.onError); // typed per-kind observer: fires only for its kind, with a non-optional event if (norm) { const handler = opts.on?.[norm.event.kind as SlackEventKind]; if (handler) runBackground(ctx, async () => { await identityReady; return handler(norm.event, ctx); }, opts.onError); } // respond: only kinds in respondTo drive a turn + reply (reactions can stay observe-only), // and of those only the events respondWhen lets through (#212) — asked inside the // background task, once the principal is resolved, like every other consumer if (norm && respondTo.includes(norm.event.kind)) { const { event, session, userText } = norm; // Debounce (#129): a new message/mention supersedes the thread's in-flight turn. // Kind-scoped so a reaction-driven turn never cancels an answer in progress. const replace = (opts.replaceInFlight && (event.kind === "message" || event.kind === "app_mention")) || undefined; runBackground(ctx, async () => { await identityReady; // the turn must carry the resolved principal (requiresPrincipal gating) if (!respondsTo(event, payload)) return; // Prefer DELIVERED rendering: the turn's host (the DO) renders the reply through // this channel's own deliver() under its OWN lifetime, so a long multi-round turn // isn't cancelled by the edge waitUntil ceiling ~30s after the ACK (the silent // no-reply failure mode). Fall back to consumer-side rendering ONLY on the typed // pre-start rejection — any other error may mean the turn already runs, and // re-running it here would double-post. if (opts.stream && ctx.runDelivered) { try { await ctx.runDelivered(userText, { session, event, replace }); return; } catch (err) { if (!(err instanceof DeliverUnsupportedError)) throw err; // the host can't deliver (channel not wired where the turn runs) and the turn // was NOT started — render from here instead } } if (opts.stream && ctx.runStream) return renderStream(ctx, event, userText, session, replace); // live: edit in place if (ctx.runStream) return renderOnce(ctx, event, userText, session, replace); // post-once, HITL-aware if (opts.status && event.threadId) await setStatus(event.channelId, event.threadId, opts.status); try { const reply = await ctx.run(userText, { session, event, replace }); // A reaction turn (or any turn) may resolve to no text — the agent acted via a // tool (e.g. slack_add_reaction) instead of posting. Only post real content. if (reply && reply.trim()) await postMessage(event.channelId, reply, event.threadId); else if (opts.status && event.threadId) await setStatus(event.channelId, event.threadId, ""); // tool-only: nothing posts to auto-clear } catch (err) { // a failed turn posts nothing to auto-clear the status — clear it before reporting if (opts.status && event.threadId) await setStatus(event.channelId, event.threadId, "").catch(() => {}); throw err; } }, opts.onError); } handled(); // everything is dispatched — this delivery is ACKed below } return new Response("", { status: 200 }); // fast ACK }, }; } // Start an agent-INITIATED (proactive) turn and render it to a target — the outbound dual of an // inbound webhook (§9). There is no inbound event: `trigger` attributes who initiated the turn // (a schedule, another channel, the agent itself), `seed` is the opening instruction the turn // acts on, and `target` is the thread the reply lands in. A schedule (e.g. a cron), another // channel handing off, or a tool calls this. Requires a streaming host (ctx.runStream) and a // channel that renders outbound (channel.deliver) — the edge and native hosts both provide // runStream; a missing one throws clearly rather than silently dropping a scheduled nudge. export async function receive( channel: Channel, ctx: ChannelContext, opts: { seed: string; target: DeliveryTarget; trigger: ProactiveTrigger; session: string; turnId?: string }, ): Promise { if (!ctx.runStream) throw new Error("receive: the host provides no runStream — proactive delivery needs a streaming host (the edge Durable Object or the native mountAgent)"); if (!channel.deliver) throw new Error(`receive: channel "${channel.name}" has no deliver() — it can't render an agent-initiated turn`); const events = ctx.runStream(opts.seed, { session: opts.session, turnId: opts.turnId, trigger: opts.trigger }); await channel.deliver(opts.target, events, { session: opts.session, files: ctx.files }); } // Slack Web API envelope shape (only the fields the read tools touch). `[k: string]` // keeps it permissive — Slack returns far more; we normalize just what an agent needs. type SlackResponse = { ok?: boolean; error?: string; messages?: { user?: string; text?: string; ts?: string; thread_ts?: string; reply_count?: number }[]; message?: { reactions?: { name: string; count: number; users: string[] }[] }; user?: { id?: string; name?: string; real_name?: string; profile?: { real_name?: string; display_name?: string } }; }; // The subset of a Slack Events API `event` we read. message/app_mention carry text; // reaction_added/removed carry `reaction` + `item` (the message reacted to). type SlackEvent = { type?: string; bot_id?: string; subtype?: string; text?: string; channel?: string; ts?: string; thread_ts?: string; channel_type?: string; user?: string; reaction?: string; item?: { type?: string; channel?: string; ts?: string }; }; // Slack sends `channel_type` on message events only, and only there is it read: a mention // reads "unknown" as documented even if a payload carried one. Without it a `D…` id is always a DM; // a `C…` id can be public or private, and a `G…` id a private channel or a group DM (older // workspaces), so neither is guessed. function slackChannelType(channelType: string | undefined, channelId: string): SlackChannelType { if (channelType === "channel" || channelType === "group" || channelType === "im" || channelType === "mpim") return channelType; return channelId.startsWith("D") ? "im" : "unknown"; } // The subset of a Slack block_actions interaction payload we read (a button click). // `response_url` lets us answer the CLICKER ephemerally (e.g. a rejected resume) without // touching the message the buttons live on. type SlackInteraction = { type?: string; user?: { id?: string }; channel?: { id?: string }; message?: { ts?: string; thread_ts?: string }; actions?: { action_id?: string; value?: string }[]; response_url?: string; }; // Map a raw Slack event to June's normalized envelope + the turn's session and text. // Returns null when the event isn't one we route (not in `events`, self-authored, or // missing required fields) so the webhook simply fast-ACKs and does nothing. Exported so // a hand-rolled Slack channel can reuse the normalization instead of re-deriving it. // The speaker is marked `attested` (#261): Slack signs the request, so `user` is Slack's // word, not a claim — which is what makes it the default answerer of a requestInput. Call // this only on a body that passed verifySlackSignature, as the built-in channel does. export function normalizeSlackEvent( e: SlackEvent, events: SlackEventKind[], botUserId?: string, // the envelope's team_id (NOT on the inner event) — chat.startStream demands it as // recipient_team_id when streaming into a channel, even in-thread (live-verified 2026-07-15) teamId?: string, ): { event: SlackNormalizedEvent; session: string; userText: string } | null { // text turns: a channel message or an @-mention. Skip our own bot + non-user subtypes, // and blank text (a whitespace-only message shouldn't burn a turn). if ((e.type === "message" || e.type === "app_mention") && events.includes(e.type) && !e.bot_id && !e.subtype && e.text && e.text.trim() && e.channel && e.ts) { const thread = e.thread_ts ?? e.ts; // reply in-thread; one session per thread return { event: { source: "slack", kind: e.type, channelId: e.channel, channelType: slackChannelType(e.type === "message" ? e.channel_type : undefined, e.channel), threadId: thread, teamId, ts: e.ts, user: e.user ? { id: e.user, attested: true } : undefined, text: e.text, raw: e }, session: `slack:${e.channel}:${thread}`, userText: e.text, }; } // reaction turns: someone added/removed an emoji on a message. No text, so synthesize // a note as the userText; the target rides on event.reaction (itemTs). botUserId guards // the bot's own reactions (from slack_add_reaction) so they don't loop into a turn. if ((e.type === "reaction_added" || e.type === "reaction_removed") && events.includes(e.type) && e.reaction && e.item?.channel && e.item?.ts) { if (botUserId && e.user === botUserId) return null; const channel = e.item.channel, itemTs = e.item.ts; const verb = e.type === "reaction_added" ? "added" : "removed"; return { event: { source: "slack", kind: e.type, channelId: channel, channelType: slackChannelType(undefined, channel), threadId: itemTs, teamId, ts: itemTs, user: e.user ? { id: e.user, attested: true } : undefined, reaction: { name: e.reaction, itemTs }, raw: e }, session: `slack:${channel}:${itemTs}`, userText: `[reaction] <@${e.user ?? "someone"}> ${verb} :${e.reaction}: on a message in this thread`, }; } return null; } // The Slack capability toolset. Split out so slackChannel stays readable and the // tools are unit-testable against fake `get`/`post`. Each tool resolves its target // from the explicit input first, then falls back to the current turn's event — so the // model can omit the ids for "this thread" / "this message". // // The fallback is gated on `event.source === "slack"`: tools are merged GLOBALLY into // an agent (defineAgent), so a multi-channel agent has slack_* tools available during a // Crisp turn too. Without the gate, a Slack tool called mid-Crisp-turn would read the // Crisp event's channelId/threadId and fire a garbage Slack call. Gating on the event's // source (not sessionId — which is "self" inside a Durable Object) is reliable on native // AND edge: a non-Slack turn simply requires explicit ids. // A Slack Web API call (GET read or POST write) — same signature, so one type serves // both the `get` (query) and `post` (JSON body) helpers passed in. type SlackCall = (method: string, params: Record) => Promise; function slackTools(get: SlackCall, post: SlackCall): Tool[] { const noTarget = (what: string) => ({ error: `no ${what} in context — pass it explicitly (this turn has no Slack event)` }); const slackEv = (ctx: ToolContext) => (ctx.event?.source === "slack" ? ctx.event : undefined); return [ { spec: { name: "slack_read_thread", description: "Read the replies in a Slack thread (defaults to the current thread). Returns each reply's author id, text, and ts.", input: { type: "object", properties: { channelId: { type: "string", description: "Channel id; defaults to the current channel" }, threadId: { type: "string", description: "Thread root ts; defaults to the current thread" }, }, }, }, run: async (input: { channelId?: string; threadId?: string }, ctx: ToolContext) => { const ev = slackEv(ctx); const channel = input.channelId ?? ev?.channelId; const ts = input.threadId ?? ev?.threadId ?? ev?.ts; if (!channel || !ts) return noTarget("thread"); const r = await get("conversations.replies", { channel, ts }); if (!r.ok) return { error: r.error ?? "slack error" }; return { messages: (r.messages ?? []).map((m) => ({ user: m.user, text: m.text, ts: m.ts })) }; }, }, { spec: { name: "slack_list_reactions", description: "List the emoji reactions on a Slack message (defaults to the message that triggered this turn): each reaction's name, count, and the user ids who reacted.", input: { type: "object", properties: { channelId: { type: "string", description: "Channel id; defaults to the current channel" }, ts: { type: "string", description: "Message ts; defaults to the current/reacted message" }, }, }, }, run: async (input: { channelId?: string; ts?: string }, ctx: ToolContext) => { const ev = slackEv(ctx); const channel = input.channelId ?? ev?.channelId; const ts = input.ts ?? ev?.reaction?.itemTs ?? ev?.ts; if (!channel || !ts) return noTarget("message"); const r = await get("reactions.get", { channel, timestamp: ts }); if (!r.ok) return { error: r.error ?? "slack error" }; return { reactions: (r.message?.reactions ?? []).map((x) => ({ name: x.name, count: x.count, users: x.users })) }; }, }, { spec: { name: "slack_resolve_user", description: "Resolve a Slack user id to their name/display name (defaults to the user who triggered this turn).", input: { type: "object", properties: { userId: { type: "string", description: "User id (e.g. U0123); defaults to the current user" } }, }, }, run: async (input: { userId?: string }, ctx: ToolContext) => { const user = input.userId ?? slackEv(ctx)?.user?.id; if (!user) return noTarget("user"); const r = await get("users.info", { user }); if (!r.ok) return { error: r.error ?? "slack error" }; const u = r.user ?? {}; return { id: u.id ?? user, name: u.name, realName: u.profile?.real_name ?? u.real_name, displayName: u.profile?.display_name }; }, }, { spec: { name: "slack_add_reaction", description: "Add an emoji reaction to a Slack message (defaults to the message that triggered this turn). Give the emoji name without colons, e.g. 'tada' or 'white_check_mark'.", input: { type: "object", properties: { name: { type: "string", description: "Emoji name without colons (e.g. thumbsup)" }, channelId: { type: "string", description: "Channel id; defaults to the current channel" }, ts: { type: "string", description: "Message ts; defaults to the current/reacted message" }, }, required: ["name"], }, }, run: async (input: { name: string; channelId?: string; ts?: string }, ctx: ToolContext) => { const ev = slackEv(ctx); const channel = input.channelId ?? ev?.channelId; const ts = input.ts ?? ev?.reaction?.itemTs ?? ev?.ts; if (!channel || !ts) return noTarget("message"); const r = await post("reactions.add", { channel, timestamp: ts, name: input.name }); // already_reacted isn't a failure for an agent — treat it as success (idempotent). if (!r.ok && r.error !== "already_reacted") return { error: r.error ?? "slack error" }; return { ok: true }; }, }, ]; } // ── crisp — customer-chat: webhooks in, REST out. Crisp has TWO webhook // contracts and they authenticate differently: // // - PLUGIN hooks (Marketplace) are signed: HMAC-SHA256 over "[{ts};{rawBody}]" // (brackets + semicolon included) plus a timestamp replay guard. Use // auth: { type: "signature", secret } — or the signingSecret shorthand. // - WEBSITE hooks (dashboard-configured) are NOT signed. Crisp's documented // pattern is a shared key in the endpoint URL ("?key=K"); the receiver // compares it. Use auth: { type: "urlKey", key }. This is weaker by // construction (no payload integrity, no replay guard, and the key rides in // the URL where access logs and proxies can see it — rotate it if logs leak) // — that's the platform's ceiling for website hooks, not a choice this // channel makes. // // Only visitor ("user") text triggers a turn by default; operator messages are our own // replies. `respondTo` widens that (a rating or a resolve can drive a follow-up turn). // // Beyond visitor-visible replies, the channel speaks Crisp's PRIVATE NOTES — the same // message endpoint with type "note", rendered in the operator inbox only (the visitor // never sees one). Three seams, one per authorship: // - the agent, mid-turn: the crisp_send_note tool hands context / findings / a // preliminary assessment to the human team even when the agent shouldn't (or // can't) answer the visitor; // - the channel, per reply: replyAs: "note" turns the ENTIRE reply path into // operator-only drafts — the not-yet-trusted-to-speak mode: turns run normally, // humans read the agent's answer in-conversation, the visitor sees nothing. // Flip back to "message" (default) to go live; // - the app, deterministically: post(target, { text, note: true }) lands an // app-authored note (e.g. a mirror-mode shadow turn's output) — no LLM choice // in the loop. // // Symmetric with slack: the visitor message becomes a normalized InboundEvent // (channelId = website, threadId = conversation session, user = the visitor), and the // channel exposes crisp_read_conversation so the agent can pull earlier messages in the // same conversation — defaulting the target from the current turn's event. // // ⚠️ Which events ARRIVE at all is decided in Crisp's dashboard/Marketplace (the hook's // event checkboxes), not here — `events`/`respondTo`/`on` only filter what arrives. The // most common "why doesn't my observer fire" is the box not being ticked on the Crisp // side. Dashboard checkbox ↔ this channel: // message:send → kind "message" (visitor text; the default turn driver) // message:updated → kind "message_changed" // session:set_state → kind "state_changed" (resolved / unresolved / pending) // session:sync:rating → kind "rating" (CSAT stars + comment) // message:received, message:removed, session:removed, people:* → no normalized kind; // they reach onEvent with a TYPED raw payload (see CrispEventPayloads / isCrispEvent). // Note: website hooks expose a SUBSET of Crisp's full catalog (no session:request:initiated, // no session:set_opened/closed, no message:compose:*) — design flows against session:set_state, // which both hook flavors deliver. export type CrispWebhookAuth = | { type: "signature"; secret: string } | { type: "urlKey"; key: string; param?: string }; // Website-hook verifier (exported for the same composability floor as // verifyCrispSignature): constant-time compare of the URL param (default // "key") against the configured key. An empty configured key always fails — // same closed-by-default posture as an empty signing secret. Accepts absolute // or path-relative URLs; anything unparseable fails closed (return false, never // throw) — same contract as the signature verifiers on bad input. export function verifyCrispUrlKey(expectedKey: string, url: string, param = "key"): boolean { if (!expectedKey) return false; let params: URLSearchParams; try { params = new URL(url, "http://relative.invalid").searchParams; } catch { return false; } return timingSafeEqual(params.get(param) ?? "", expectedKey); } // Exactly one of `auth` / `signingSecret`, enforced at the type level so a // misconfigured channel is a compile error, not a runtime 401 — signingSecret is // the plugin-hook shorthand, kept for compatibility: it means // { type: "signature", secret }. (The constructor re-checks at runtime as a // backstop for plain-JS callers.) type CrispAuthOpts = | { auth: CrispWebhookAuth; signingSecret?: never } | { auth?: never; signingSecret: string }; // Crisp REST paths interpolate website/session ids the tools accept as MODEL-SUPPLIED // arguments — raw interpolation would let reserved characters or dot segments retarget // the authenticated call. encodeURIComponent alone is NOT enough: it leaves "." unescaped, // so a "." / ".." id survives encoding and URL parsing then normalizes the segment away // (/conversation/../message → /message). Reject exact dot-segments outright, then encode; // a blank id has no valid target either, so it fails the same way. function crispPathSegment(id: string): string { const trimmed = id.trim(); if (!trimmed) throw new Error("crisp: empty id in REST path"); if (trimmed === "." || trimmed === "..") throw new Error(`crisp: invalid id in REST path (${JSON.stringify(id)})`); return encodeURIComponent(trimmed); } // ── crisp typed payloads + normalization ────────────────────────────────────── // Curated `data` shapes for the dashboard-subscribable events an agent app actually // consumes — typed so an onEvent consumer gets autocomplete and typo-checking instead // of hand-rolling shapes from the Crisp docs. Deliberately NOT the full ~70-event // catalog: the long tail (campaign:*, bucket:*, email:*, …) stays `unknown` and is // still reachable through onEvent's raw. All fields optional — Crisp payloads are // external input; normalization (below) is where required fields are enforced. export type CrispMessageType = "text" | "file" | "animation" | "audio" | "picker" | "field" | "carousel" | "note" | "event"; export type CrispUser = { user_id?: string; nickname?: string }; type CrispMessageData = { website_id?: string; session_id?: string; from?: "user" | "operator"; type?: CrispMessageType; content?: unknown; fingerprint?: number; user?: CrispUser; timestamp?: number; }; export type CrispEventPayloads = { "message:send": CrispMessageData; // a visitor message (the default turn driver) "message:received": CrispMessageData; // an operator message (our own replies included — loop hazard, never a turn) "message:updated": { website_id?: string; session_id?: string; fingerprint?: number; content?: unknown }; "message:removed": { website_id?: string; session_id?: string; fingerprint?: number }; "session:set_state": { website_id?: string; session_id?: string; state?: "pending" | "unresolved" | "resolved" }; "session:sync:rating": { website_id?: string; session_id?: string; rating?: { stars?: number; comment?: string } }; "session:removed": { website_id?: string; session_id?: string }; }; export type CrispEventName = keyof CrispEventPayloads; // The webhook envelope around every Crisp event — what actually arrives at the // endpoint, parsed from untrusted JSON (hence all-optional, the module's posture // for external input). Exported (#91) so an app consuming onEvent raws doesn't // re-declare this shape; isCrispEvent narrows `event`/`data` within it. export type CrispWebhookEnvelope = { website_id?: string; event?: string; data?: unknown; timestamp?: number }; // Narrow an onEvent `raw` to a typed Crisp payload: // if (isCrispEvent(raw, "session:sync:rating")) record(raw.data.rating?.stars) // Checks the discriminant AND that `data` is an object (#91): the envelope is parsed // from untrusted JSON, and a malformed delivery with a null/scalar `data` used to pass // the guard and throw on the first `payload.data.x` access downstream. The data SHAPE // beyond that remains a trusted-typing convenience, the same posture as every other // webhook payload cast in this module. export function isCrispEvent( payload: unknown, event: E, ): payload is CrispWebhookEnvelope & { event: E; data: CrispEventPayloads[E]; timestamp?: number } { if (typeof payload !== "object" || payload === null) return false; const p = payload as { event?: unknown; data?: unknown }; return p.event === event && typeof p.data === "object" && p.data !== null; } // The normalized kinds crispChannel can produce (a subset of InboundEvent["kind"]). export type CrispEventKind = "message" | "message_changed" | "state_changed" | "rating"; // Map a raw Crisp webhook payload to June's normalized envelope + the turn's session and // text — the crisp dual of normalizeSlackEvent, exported so a hand-rolled channel reuses // the normalization. Returns null when the event isn't one we route (not in `events`, // operator/self-authored, or missing required fields) so the webhook fast-ACKs and does // nothing. Kinds without natural text (state_changed / rating) synthesize a note as the // userText — same pattern as Slack reaction turns. export function normalizeCrispEvent( payload: { event?: string; data?: unknown }, events: CrispEventKind[], ): { event: InboundEvent; session: string; userText: string } | null { const base = (d: { website_id?: string; session_id?: string }) => ({ source: "crisp", channelId: d.website_id!, threadId: d.session_id!, raw: payload }) as const; const session = (d: { website_id?: string; session_id?: string }) => `crisp:${d.website_id}:${d.session_id}`; // one conversation = one session // A VISITOR text message (operator messages — message:received, or a message:send that // isn't visitor-authored — are our own reply path → loop guard; require non-blank content so // a whitespace message doesn't burn a turn). channelId = website, threadId = conversation // session (NOT ts — Crisp keys a conversation by website/session; ts is just the message // fingerprint, "" when Crisp omits it). if (isCrispEvent(payload, "message:send") && events.includes("message")) { const d = payload.data ?? {}; if (d.from === "user" && d.type === "text" && typeof d.content === "string" && d.content.trim() && d.website_id && d.session_id) { return { event: { ...base(d), kind: "message", ts: String(d.fingerprint ?? ""), user: d.user?.user_id ? { id: d.user.user_id, name: d.user.nickname } : undefined, text: d.content }, session: session(d), userText: d.content, }; } return null; } // A message edit. ⚠️ Crisp's payload carries NO `from` — visitor and operator edits are // indistinguishable, so putting "message_changed" in respondTo can burn a turn on an // operator's own edit. Fine for observers; opt into turns knowingly. if (isCrispEvent(payload, "message:updated") && events.includes("message_changed")) { const d = payload.data ?? {}; if (typeof d.content === "string" && d.content.trim() && d.website_id && d.session_id) { return { event: { ...base(d), kind: "message_changed", ts: String(d.fingerprint ?? ""), text: d.content }, session: session(d), userText: `[edited] a message in this conversation was edited to: ${d.content}`, }; } return null; } // The conversation's state machine (resolved / unresolved / pending) — the hook both // website and plugin hooks deliver for "the operator resolved it" (session:set_closed // does NOT reach website hooks). The natural trigger for a resolve hand-off. if (isCrispEvent(payload, "session:set_state") && events.includes("state_changed")) { const d = payload.data ?? {}; if (d.state && d.website_id && d.session_id) { return { event: { ...base(d), kind: "state_changed", ts: "", state: d.state }, session: session(d), userText: `[state] the conversation was marked ${d.state}`, }; } return null; } // The visitor's CSAT rating — stars (+ optional comment) ride on event.rating so a // follow-up turn (respondTo: ["rating"]) can react to a bad score. if (isCrispEvent(payload, "session:sync:rating") && events.includes("rating")) { const d = payload.data ?? {}; if (typeof d.rating?.stars === "number" && d.website_id && d.session_id) { return { event: { ...base(d), kind: "rating", ts: "", rating: { stars: d.rating.stars, comment: d.rating.comment } }, session: session(d), userText: `[rating] the visitor rated this conversation ${d.rating.stars}/5${d.rating.comment ? `: "${d.rating.comment}"` : ""}`, }; } return null; } return null; } // ── crisp identity (WHO IS SPEAKING) ────────────────────────────────────────── // Crisp has TWO unrelated HMACs and only one of them says anything about the visitor: // the webhook signature (verified above) proves "Crisp sent this payload"; the // IDENTITY VERIFICATION signature (your backend signs the visitor's email with the // site's secret and the chatbox submits both) proves "our own server vouched for this // email". Crisp records the latter on the CONVERSATION — `verifications: [{ identity: // "email", method, annotation: }]` — NOT in the webhook payload and NOT in // `meta` (meta/session:data is client-writable from the widget: treat it as hints, // never as authorization input). So identity is always PULLED over authenticated REST, // which also makes it equally trustworthy under both webhook auth modes (urlKey // website hooks carry no payload integrity at all). // // Trust is deliberately narrow: only methods your backend asserts count as verified — // sdk the chatbox HMAC path ($crisp user:email with a signature) // api the email was set server-side over authenticated REST // "link" (visitor clicked a magic link), "dkim"/"spf" (sender domain aligned) prove // weaker or different things; they stay visible in `verifications` so a resolver that // wants them can widen trust explicitly. export type CrispVerification = { identity?: string; method?: string; annotation?: string }; export type CrispIdentity = { websiteId: string; sessionId: string; fetched: boolean; // false → the REST lookup failed; every field below is its fail-closed default verified: boolean; // a backend-asserted email verification exists (method sdk/api) email?: string; // the VERIFIED email (verifications[].annotation) — absent unless verified method?: string; // which trusted method verified it verifications: CrispVerification[]; // the raw array (empty when Crisp sends [] or null) meta: { nickname?: string; email?: string; data?: Record }; // CLAIMED, client-writable — hints only }; const TRUSTED_EMAIL_METHODS = ["sdk", "api"]; // Pure derivation from a fetched conversation — exported so a hand-rolled channel (or a // test) reuses the trust rule without the factory. Tolerates the shapes Crisp actually // sends: `verifications` may be an array, `[]`, `null`, or absent. export function deriveCrispIdentity( conversation: { verifications?: CrispVerification[] | null; meta?: CrispIdentity["meta"] | null } | undefined, target: { websiteId: string; sessionId: string; fetched?: boolean }, ): CrispIdentity { const verifications = conversation?.verifications ?? []; const hit = verifications.find((v) => v.identity === "email" && !!v.method && TRUSTED_EMAIL_METHODS.includes(v.method) && !!v.annotation); return { websiteId: target.websiteId, sessionId: target.sessionId, fetched: target.fetched ?? true, verified: hit !== undefined, email: hit?.annotation, method: hit?.method, verifications, meta: conversation?.meta ?? {}, }; } export function crispChannel(opts: CrispAuthOpts & { identifier: string; key: string; // Which REST token type `identifier`/`key` is — rides on every outbound call as // X-Crisp-Tier. "plugin" (default, the prior behavior) for Marketplace plugin // tokens; "website" for dashboard-generated website tokens (workspace-scoped, // Settings → Advanced → API Token). tier?: "plugin" | "website"; path?: string; apiUrl?: string; // The identity seam: resolve WHO IS SPEAKING before any consumer of a normalized // event runs. The channel pulls the conversation's verification evidence over // authenticated REST (see CrispIdentity above) and hands it here; the app maps a // trusted email to its own Principal (tenant, roles, …) — or returns null/undefined // to leave the turn anonymous. The result is pinned on event.principal, flows to // ToolContext.principal, and gates Tool.requiresPrincipal tools. Fail-closed // everywhere: a failed REST lookup arrives as { fetched: false, verified: false }, // a throwing resolver is reported to onError and the turn runs anonymous. Costs one // REST GET per normalized event — configure it only when tools need identity. resolveIdentity?: (identity: CrispIdentity, ctx: ChannelContext) => Promise | Principal | null | undefined; // Which arriving events NORMALIZE (become an InboundEvent) — "message" (visitor text) // by default. Same derivation as slack: expressing intent via respondTo/on derives the // list; explicit `events` overrides. Remember the dashboard checkbox decides what // arrives at all (see the header note) — this only filters. events?: CrispEventKind[]; // Which normalized kinds drive a turn + reply; the rest only reach `on`/`onEvent`. // Defaults to all of `events` (mode:"observe" forces this empty). e.g. // events:["message","rating"], respondTo:["message"] runs a turn per visitor message // but treats a CSAT rating as a deterministic observe (no LLM); respondTo:["message", // "rating"] lets a bad score drive a follow-up turn (userText is a synthesized note, // like Slack reaction turns). respondTo?: CrispEventKind[]; // How the reply path DELIVERS a turn's reply: "message" (default) is the visitor- // visible operator message; "note" lands it as a PRIVATE note only human operators // see (same endpoint, type "note"). The supervised rollout mode — the agent runs // real turns and its answers sit in the operator inbox as suggestions, but it is // not yet allowed to speak to the visitor. Orthogonal to respondTo (which kinds // turn) and to the crisp_send_note tool (agent-authored notes mid-turn, any mode). replyAs?: "message" | "note"; // Per-kind observers: `on[kind]` fires (background) only for that kind, only when a // normalized event exists (post loop guards) — no onEvent-style demux. A kind present // here is auto-subscribed (see the `events` derivation). onEvent stays the raw // firehose over ALL verified events, normalizable or not. on?: Partial>; onError?: (err: unknown) => void; } & ChannelExtensions): Channel { const api = opts.apiUrl ?? "https://api.crisp.chat/v1"; // Derive the normalize list from intent (respondTo + on keys) so kinds aren't written // twice and can't drift; explicit `events` overrides. No intent at all → the friendly // default (visitor messages only — the prior behavior). const onKinds = Object.keys(opts.on ?? {}) as CrispEventKind[]; const events: CrispEventKind[] = opts.events ?? (opts.respondTo || opts.on ? [...new Set([...(opts.respondTo ?? []), ...onKinds])] : ["message"]); const respondTo: string[] = opts.mode === "observe" ? [] : (opts.respondTo ?? events); const auth = () => `Basic ${btoa(`${opts.identifier}:${opts.key}`)}`; const tier = opts.tier ?? "plugin"; // replyAs is a CONFIDENTIALITY boundary: a plain-JS typo ("notes") falling through // to the visitor-visible branch would make a supervised deployment public. Fail at // construction, like the auth-mode check — a backstop for untyped callers. Default // ONLY on a genuinely absent option (undefined): an explicit null/garbage value is // a confused caller, not a request for the public default. const replyAs: "message" | "note" = opts.replyAs === undefined ? "message" : opts.replyAs; if (replyAs !== "message" && replyAs !== "note") throw new Error(`crispChannel: replyAs must be "message" or "note" (got ${JSON.stringify(opts.replyAs)})`); // The one outbound message call — text messages and private notes are the same // endpoint, differing only in `type`. Returns Crisp's envelope so callers pick // their own strictness: the reply path stays best-effort, post/tool check it. async function postMessage(websiteId: string, sessionId: string, content: string, type: "text" | "note") { const res = await fetch(`${api}/website/${crispPathSegment(websiteId)}/conversation/${crispPathSegment(sessionId)}/message`, { method: "POST", headers: { "content-type": "application/json", authorization: auth(), "X-Crisp-Tier": tier }, body: JSON.stringify({ type, from: "operator", origin: "chat", content }), }); return (await res.json().catch(() => ({}))) as { error?: boolean; reason?: string; data?: { fingerprint?: number } }; } async function crispGet(path: string): Promise { const res = await fetch(`${api}${path}`, { headers: { authorization: auth(), "X-Crisp-Tier": tier } }); return (await res.json()) as T; } // Pull the conversation's verification evidence (see the identity section above). // NEVER throws — any failure (network, non-JSON, Crisp error envelope) degrades to // the fail-closed identity so the resolver always runs with honest inputs. async function fetchIdentity(websiteId: string, sessionId: string): Promise { try { const r = await crispGet<{ error?: boolean; data?: { verifications?: CrispVerification[] | null; meta?: CrispIdentity["meta"] | null } }>( `/website/${crispPathSegment(websiteId)}/conversation/${crispPathSegment(sessionId)}`, ); if (r.error || !r.data) return deriveCrispIdentity(undefined, { websiteId, sessionId, fetched: false }); return deriveCrispIdentity(r.data, { websiteId, sessionId }); } catch { return deriveCrispIdentity(undefined, { websiteId, sessionId, fetched: false }); } } // Resolve the auth mode once, loudly: a channel that silently 401s every // delivery because neither source was configured is much harder to diagnose // than a construction-time throw. if (opts.auth && opts.signingSecret !== undefined) throw new Error("crispChannel: pass either `auth` or `signingSecret`, not both"); const webhookAuth: CrispWebhookAuth | undefined = opts.auth ?? (opts.signingSecret !== undefined ? { type: "signature", secret: opts.signingSecret } : undefined); if (!webhookAuth) throw new Error("crispChannel: one of `auth` or `signingSecret` is required"); return { name: "crisp", path: opts.path ?? "/channels/crisp", tools: () => crispTools(crispGet, postMessage), // Deterministic outbound post (#89), the crisp dual: an operator message into a // conversation, returning its fingerprint as the identity `ts`. Requires the // conversation session as target.threadId (crisp has no channel-only target), and // text content (no block concept); `note: true` lands it as a PRIVATE operator-only // note (same endpoint, type "note"). Throws loudly on a platform error, unlike the // best-effort reply path. async post(target, content) { if (!target.threadId) throw new Error("crisp: post needs target.threadId (the conversation session id)"); const text = typeof content === "string" ? content : content.text; if (!text?.trim()) throw new Error("crisp: post needs text content (blocks have no crisp mapping)"); // `note` is a confidentiality marker — an untyped caller's { note: "true" } must // not fall through to the visitor-visible branch. Anything but a boolean (or // absent) fails closed, the post() dual of the replyAs construction guard. const note = typeof content === "string" ? undefined : content.note; if (note !== undefined && typeof note !== "boolean") throw new Error(`crisp: post note must be a boolean (got ${JSON.stringify(note)})`); const r = await postMessage(target.channelId, target.threadId, text, note ? "note" : "text"); if (r.error !== false || r.data?.fingerprint === undefined) throw new Error(`crisp: message send failed (${r.reason ?? "no response"})`); return { channelId: target.channelId, threadId: target.threadId, ts: String(r.data.fingerprint) }; }, async webhook(req, ctx) { // urlKey authenticates from the URL alone — reject before reading the body, // so an invalid-key request costs a URL parse and nothing else. Signature // mode MACs the raw body, so it has to read first. if (webhookAuth.type === "urlKey" && !verifyCrispUrlKey(webhookAuth.key, req.url, webhookAuth.param)) { reject(opts, req, { kind: "bad_signature", detail: "url key" }); return new Response("bad key", { status: 401 }); } const body = await req.text(); if (webhookAuth.type === "signature") { const ok = await verifyCrispSignature( webhookAuth.secret, req.headers.get("x-crisp-request-timestamp") ?? "", body, req.headers.get("x-crisp-signature") ?? "", ); if (!ok) { reject(opts, req, { kind: "bad_signature" }); return new Response("bad signature", { status: 401 }); } } const payload = tryParseJson<{ event?: string; data?: unknown }>(body); if (!payload) { reject(opts, req, { kind: "malformed_body" }); return new Response("", { status: 200 }); } // signed but unparseable → ACK, don't retry if (opts.accept && !opts.accept(payload)) return new Response("", { status: 200 }); // gated out (e.g. website allowlist) const norm = normalizeCrispEvent(payload, events); // identity: when the seam is configured, resolve WHO IS SPEAKING once per // normalized event and pin it on event.principal BEFORE any consumer runs — the // observers and the turn all await this shared promise, so observe-mode apps (a // shadow pipeline running its own turns from onEvent) see the same principal the // respond path would. Never rejects: fetchIdentity fails closed and a throwing // resolver is reported to onError, leaving the event anonymous. const identityReady: Promise = norm && opts.resolveIdentity ? (async () => { const identity = await fetchIdentity(norm.event.channelId, norm.event.threadId!); try { const principal = await opts.resolveIdentity!(identity, ctx); if (principal != null) norm.event.principal = principal; } catch (err) { try { opts.onError?.(err); } catch { /* error reporting failed — nothing more to do */ } } })() : Promise.resolve(); // observe: mirror EVERY verified event (visitor + operator + non-text) — this is how // an app records the whole conversation without forking the channel. Narrow `raw` // with isCrispEvent for typed access to the un-normalized kinds (message:received…). if (opts.onEvent) runBackground(ctx, async () => { await identityReady; return opts.onEvent!({ raw: payload, event: norm?.event }, ctx); }, opts.onError); // typed per-kind observer: fires only for its kind, with a non-optional event if (norm) { const handler = opts.on?.[norm.event.kind as CrispEventKind]; if (handler) runBackground(ctx, async () => { await identityReady; return handler(norm.event, ctx); }, opts.onError); } // respond: only kinds in respondTo drive a turn + reply (a rating can stay observe-only). // replyAs decides how the reply lands: the visitor-visible message (default) or a // private operator-only note (the supervised rollout mode — see the opt). if (norm && respondTo.includes(norm.event.kind)) { const { event, session, userText } = norm; runBackground(ctx, async () => { await identityReady; const reply = await ctx.run(userText, { session, event }); if (reply && reply.trim()) await postMessage(event.channelId, event.threadId!, reply, replyAs === "note" ? "note" : "text"); // skip empty (tool-only turn) }, opts.onError); } return new Response("", { status: 200 }); // fast ACK }, }; } // Crisp REST envelope (only what the read tool touches). Crisp wraps payloads in // `{ error, data }`; message objects carry from/type/content/timestamp. type CrispResponse = { error?: boolean; reason?: string; data?: { from?: string; type?: string; content?: unknown; timestamp?: number; user?: { nickname?: string; user_id?: string } }[]; }; // The Crisp capability toolset — symmetric with slackTools. crisp_read_conversation // pulls the message history of a conversation; crisp_send_note leaves a PRIVATE // operator-only note in it (the agent's channel to the human team — the visitor never // sees a note, so it is safe in any mode, including before the agent is trusted to // speak). Both default website/session from the current turn's event // (channelId/threadId). function crispTools( get: (path: string) => Promise, sendMessage: (websiteId: string, sessionId: string, content: string, type: "text" | "note") => Promise<{ error?: boolean; reason?: string; data?: { fingerprint?: number } }>, ): Tool[] { const noConversation = { error: "no conversation in context — pass websiteId and sessionId (this turn has no Crisp event)" }; // gate on source: in a multi-channel agent these tools are available during a Slack // turn too; only default from a genuine Crisp event (see slackTools for the why). const crispEv = (ctx: ToolContext) => (ctx.event?.source === "crisp" ? ctx.event : undefined); // MODEL-SUPPLIED ids resolve here: a blank id has no target (model-readable error, not // a throw), and crispPathSegment at the call sites below encodes what passes — a "../" // or reserved character must not retarget the authenticated call. const target = (input: { websiteId?: string; sessionId?: string }, ctx: ToolContext) => { const ev = crispEv(ctx); const website = input.websiteId ?? ev?.channelId; const session = input.sessionId ?? ev?.threadId; // a valid id must survive crispPathSegment: non-blank and not a "."/".." dot-segment // that URL normalization would collapse away — otherwise there is no real target. const usable = (id?: string) => !!id && id.trim() !== "" && id.trim() !== "." && id.trim() !== ".."; return usable(website) && usable(session) ? { website: website!, session: session! } : undefined; }; return [ { spec: { name: "crisp_read_conversation", description: "Read the earlier messages in this Crisp conversation (defaults to the current conversation). Returns each message's sender (user/operator), type, and content.", input: { type: "object", properties: { websiteId: { type: "string", description: "Website id; defaults to the current one" }, sessionId: { type: "string", description: "Conversation session id; defaults to the current one" }, }, }, }, run: async (input: { websiteId?: string; sessionId?: string }, ctx: ToolContext) => { const t = target(input, ctx); if (!t) return noConversation; const r = await get(`/website/${crispPathSegment(t.website)}/conversation/${crispPathSegment(t.session)}/messages`); if (r.error) return { error: r.reason ?? "crisp error" }; return { messages: (r.data ?? []).map((m) => ({ from: m.from, type: m.type, content: m.content, nickname: m.user?.nickname })) }; }, }, { spec: { name: "crisp_send_note", description: "Leave a PRIVATE note on this Crisp conversation (defaults to the current one). Notes are visible to human operators only — the visitor never sees them. Use it to hand the human team reference context, findings, or your preliminary assessment, especially when you should not (or cannot) reply to the visitor directly.", input: { type: "object", properties: { content: { type: "string", description: "The note text for the human operators" }, websiteId: { type: "string", description: "Website id; defaults to the current one" }, sessionId: { type: "string", description: "Conversation session id; defaults to the current one" }, }, required: ["content"], }, }, run: async (input: { content: string; websiteId?: string; sessionId?: string }, ctx: ToolContext) => { const t = target(input, ctx); if (!t) return noConversation; if (!input.content?.trim()) return { error: "note content is empty" }; const r = await sendMessage(t.website, t.session, input.content, "note"); // a success without a fingerprint is a malformed envelope — report it honestly // instead of promising an identity the note doesn't have (post() does the same) if (r.error !== false || r.data?.fingerprint === undefined) return { error: r.reason ?? "crisp error" }; // fingerprint = the note's message identity, so the agent can reference it later return { ok: true, fingerprint: r.data.fingerprint }; }, }, ]; }