diff --git a/apps/slack-agent/README.md b/apps/slack-agent/README.md
index e57b6f107..a938a1da5 100644
--- a/apps/slack-agent/README.md
+++ b/apps/slack-agent/README.md
@@ -43,6 +43,8 @@ agent/
tools/render_chart.ts # renders a PNG chart in-process and posts it into the thread
tools/{bash,glob,grep,read_file,write_file,web_fetch,web_search}.ts
# `disableTool()` sentinels — see Framework tools below
+ lib/thread-context.ts # full-thread turn context (renders Block Kit / alert cards too)
+ lib/bot-identity.ts # per-team bot user id learned from the webhook envelope
lib/chart.ts # pure SVG chart renderer + unicode-sparkline fallback
lib/slack-upload.ts # Slack external-upload flow (files.getUploadURLExternal → complete)
lib/env.ts # shared is-this-deployed predicate (route auth + token fallback)
@@ -337,7 +339,8 @@ eve's native idiom:
- **Modes → skills:** the web chat's dashboard-builder and investigate modes are progressive-
disclosure skills (`agent/skills/dashboard-builder/`, `agent/skills/incident-investigation/`)
the model loads via `load_skill`. Alert context comes from the Slack thread (Maple delivers
- alert notifications into Slack), not from a request payload.
+ alert notifications into Slack), not from a request payload — including the alert card itself,
+ which `agent/lib/thread-context.ts` renders out of its Block Kit attachment (see Notes).
- **Approvals — both sides now interrupt, by different mechanisms:** the web chat stops the turn on
a gated tool and emits a `tool-call` with `proposed: true` and no result
(`apps/api/src/chat/agent.ts`); the user approves and `POST /api/chat/apply` performs the
@@ -490,6 +493,19 @@ and **activate public distribution** so the app can be installed into any worksp
The SSE must carry `delta.tool_calls`, not a JSON blob inside the text content. Also set
`OPENROUTER_CONTEXT_WINDOW` to the new model's window.
+- **Thread context is ours, not `slackChannel({ threadContext })`.** Every mention and DM ships the
+ whole thread transcript with the turn, rendered by `agent/lib/thread-context.ts` and returned as
+ the mention result's `context` from `onAppMention` / `onDirectMessage`. eve's built-in option
+ couldn't carry an alert thread — the case that matters most, since the user is replying to
+ something Maple posted rather than opening a topic. It reads a message's content from `text`
+ alone, and Maple's alert notifications have none: the blocks ride inside a colored attachment
+ (`apps/api/src/services/alerts/AlertDeliveryDispatch.ts`), so the model saw an empty
+ ``. Worse, `since: "last-agent-reply"` counted the alert as the agent's own
+ reply (eve's `isMe` is `bot_id !== undefined` — any bot) and cut context off _after_ it, dropping
+ the alert entirely. The user had to hand the bot a recap of the alert it had just sent. Ours
+ falls back to blocks/attachments for content, keeps the full thread (which also survives a
+ session lost to a redeploy), and attributes speakers with the workspace's real bot user id from
+ `agent/lib/bot-identity.ts` so a third-party app in the channel isn't quoted back as the agent.
- **Auth:** `agent/channels/eve.ts` fails closed in deployed environments (`RAILWAY_ENVIRONMENT_NAME`
set, or `NODE_ENV=production`): the browser/API routes always require HTTP Basic there. With
`ROUTE_AUTH_BASIC_PASSWORD` set that's your stable credential; without it, a random per-boot
diff --git a/apps/slack-agent/agent/channels/slack.ts b/apps/slack-agent/agent/channels/slack.ts
index 0200bbbd9..276d947c4 100644
--- a/apps/slack-agent/agent/channels/slack.ts
+++ b/apps/slack-agent/agent/channels/slack.ts
@@ -1,8 +1,10 @@
-import { slackChannel } from "eve/channels/slack"
-import type { SlackWebhookVerifier } from "eve/channels/slack"
+import { defaultSlackAuth, slackChannel } from "eve/channels/slack"
+import type { SlackContext, SlackMentionResult, SlackMessage, SlackWebhookVerifier } from "eve/channels/slack"
import { acknowledgeIncomingMessage } from "#lib/ack-reaction.js"
import { describeActions, truncateTypingStatus } from "#lib/action-status.js"
+import { botUserIdForTeam, rememberBotUserId } from "#lib/bot-identity.js"
import { resolveBotToken, verifySlackV0Signature, type SlackTokenContext } from "#lib/maple.js"
+import { loadThreadContext } from "#lib/thread-context.js"
import { promoteThreadFollowUp } from "#lib/thread-follow-up.js"
import { forwardUninstallEvent } from "#lib/uninstall-detection.js"
@@ -38,6 +40,11 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => {
return false
}
+ // Every event_callback names this workspace's bot user under
+ // `authorizations`, so thread-context attribution gets it for free — no
+ // auth.test round-trip (#lib/bot-identity.js).
+ rememberBotUserId(body)
+
// app_uninstalled / tokens_revoked: eve only dispatches app_mention + DM
// events downstream, so it would otherwise drop these as "unsupported".
// Fired without awaiting — it must never delay this webhook's ack, and it
@@ -75,6 +82,34 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => {
return body
}
+/**
+ * Stands in for eve's default mention/DM pipeline: same two steps — a
+ * "Thinking..." typing indicator and workspace-scoped auth derivation — plus
+ * the thread transcript as turn context.
+ *
+ * The context is ours rather than the channel's `threadContext` option because
+ * eve reads a message's content from `text` alone and treats every bot post as
+ * the agent's own last reply. Maple's alert notifications are Block Kit inside
+ * an attachment with no top-level text, so both rules misfired at once and an
+ * @mention in an alert thread reached the model with the alert blank and
+ * everything before it cut away — see #lib/thread-context.js.
+ *
+ * Runs after eve has already returned 200 to Slack (`waitUntil`), so the thread
+ * fetch is off the webhook's delivery budget. It must not throw: eve drops the
+ * whole mention when this handler does, so `loadThreadContext` degrades to no
+ * context instead.
+ */
+async function dispatchWithThreadContext(
+ ctx: SlackContext,
+ message: SlackMessage,
+): Promise {
+ await ctx.thread.startTyping("Thinking...")
+ const context = await loadThreadContext(ctx.thread, message, {
+ botUserId: botUserIdForTeam(message.teamId),
+ })
+ return { auth: defaultSlackAuth(message, ctx), context }
+}
+
export default slackChannel({
credentials: {
webhookVerifier,
@@ -83,9 +118,10 @@ export default slackChannel({
// SLACK_BOT_TOKEN.
botToken: async (context?: SlackTokenContext) => resolveBotToken(context),
},
- // Repeated mentions in a thread only inject what's new since the agent's
- // last reply, instead of re-reading the whole thread each time.
- threadContext: { since: "last-agent-reply" },
+ // Thread context is loaded by these two handlers, not by the channel's
+ // `threadContext` option — see dispatchWithThreadContext above.
+ onAppMention: dispatchWithThreadContext,
+ onDirectMessage: dispatchWithThreadContext,
events: {
// Eve's default flashes the raw tool-call label (`maple__list_services
// startTime=...`) into the typing status. Replace it with a plain
diff --git a/apps/slack-agent/agent/instructions.md b/apps/slack-agent/agent/instructions.md
index 9748e0b10..d5426a163 100644
--- a/apps/slack-agent/agent/instructions.md
+++ b/apps/slack-agent/agent/instructions.md
@@ -108,6 +108,12 @@ reader's next move.
never mention or describe the reaction in your reply text.
- You are replying inside a Slack thread; stay on topic for that thread, and
when several people are involved, pay attention to who is asking.
+- The turn carries the thread so far in ``. Read it
+ before answering and never ask the user to restate what is already there.
+ When it opens with a Maple alert notification (`sender_type: bot`, a rule
+ name with a severity, an observed value, and an incident link), that alert is
+ the subject: take the rule, window, and incident id straight from it and load
+ the incident-investigation skill.
- When you don't know something, say so plainly rather than guessing.
### Length calibration
diff --git a/apps/slack-agent/agent/lib/bot-identity.ts b/apps/slack-agent/agent/lib/bot-identity.ts
new file mode 100644
index 000000000..139240f6f
--- /dev/null
+++ b/apps/slack-agent/agent/lib/bot-identity.ts
@@ -0,0 +1,88 @@
+/**
+ * Per-workspace bot user id, learned from inbound webhook envelopes.
+ *
+ * Every `event_callback` Slack delivers names the app's own bot user under
+ * `authorizations[].user_id`, so the id is free — no `auth.test` round-trip and
+ * no extra secret. The webhook verifier records it (`rememberBotUserId`) and
+ * later stages read it back per team (`botUserIdForTeam`).
+ *
+ * Why it is needed at all: eve marks a thread message as the agent's own with
+ * `isMe = bot_id !== undefined`, i.e. *any* bot. In a channel that also hosts a
+ * CI or GitHub app, that mislabels a stranger's post as something this agent
+ * said. Thread context (`#lib/thread-context.js`) attributes speakers with this
+ * id instead.
+ *
+ * The cache is process-global and keyed by team id, which is the only
+ * multi-tenant-safe key here: this app serves every workspace that installed
+ * the Slack app.
+ */
+
+import { createTtlCache } from "./ttl-cache.js"
+
+// A workspace's bot user id is stable for the life of the installation, so the
+// TTL only bounds how long an uninstalled workspace lingers in memory.
+const BOT_USER_ID_TTL_MS = 24 * 60 * 60_000
+const CACHE_MAX_ENTRIES = 500
+const CACHE_SWEEP_INTERVAL_MS = 60 * 60_000
+
+interface BotUserIdEntry {
+ readonly botUserId: string
+ readonly expiresAt: number
+}
+
+const botUserIds = createTtlCache({
+ maxEntries: CACHE_MAX_ENTRIES,
+ sweepIntervalMs: CACHE_SWEEP_INTERVAL_MS,
+})
+
+/** Test-only: clears the cache so each test starts cold. */
+export function resetBotUserIdCacheForTests(): void {
+ botUserIds.clear()
+}
+
+/**
+ * The event envelope's `authorizations` names the app's bot user — no
+ * `auth.test` round-trip needed. Returns null when the envelope carries none.
+ */
+export function botUserIdFromEnvelope(envelope: Record): string | null {
+ const authorizations = envelope.authorizations
+ if (!Array.isArray(authorizations)) return null
+ for (const auth of authorizations) {
+ if (isRecord(auth) && typeof auth.user_id === "string" && auth.user_id.length > 0) {
+ return auth.user_id
+ }
+ }
+ return null
+}
+
+/**
+ * Records the bot user id carried by a verified inbound webhook body. Never
+ * throws — a body we cannot read simply teaches us nothing.
+ */
+export function rememberBotUserId(rawBody: string): void {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(rawBody)
+ } catch {
+ return
+ }
+ if (!isRecord(parsed)) return
+ const teamId = typeof parsed.team_id === "string" ? parsed.team_id : ""
+ const botUserId = botUserIdFromEnvelope(parsed)
+ if (!teamId || !botUserId) return
+ botUserIds.set(teamId, { botUserId, expiresAt: Date.now() + BOT_USER_ID_TTL_MS })
+}
+
+/**
+ * The bot user id for a workspace, or undefined when no webhook has taught us
+ * one yet. Callers must degrade gracefully rather than block on it: the id is
+ * an attribution refinement, never a gate.
+ */
+export function botUserIdForTeam(teamId: string | undefined): string | undefined {
+ if (!teamId) return undefined
+ return botUserIds.get(teamId)?.botUserId
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
diff --git a/apps/slack-agent/agent/lib/thread-context.test.ts b/apps/slack-agent/agent/lib/thread-context.test.ts
new file mode 100644
index 000000000..d74374c48
--- /dev/null
+++ b/apps/slack-agent/agent/lib/thread-context.test.ts
@@ -0,0 +1,273 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import type { SlackThreadMessage } from "eve/channels/slack"
+import { botUserIdForTeam, rememberBotUserId, resetBotUserIdCacheForTests } from "./bot-identity.js"
+import { formatThreadContext, loadThreadContext, slackMessageContent } from "./thread-context.js"
+
+afterEach(() => {
+ resetBotUserIdCacheForTests()
+})
+
+const BOT_USER_ID = "UBOT"
+
+const message = (overrides: Partial = {}): SlackThreadMessage => ({
+ text: "",
+ markdown: "",
+ user: undefined,
+ botId: undefined,
+ ts: "1700000000.000100",
+ threadTs: "1700000000.000100",
+ isMe: false,
+ raw: {},
+ ...overrides,
+})
+
+const userMessage = (text: string, ts: string): SlackThreadMessage =>
+ message({ text, markdown: text, user: "U1", ts, threadTs: "1700000000.000100" })
+
+/**
+ * Shape of a Maple alert notification as it comes back from
+ * `conversations.replies`: no top-level text, blocks inside one colored
+ * attachment. Mirrors apps/api/src/services/alerts/AlertDeliveryDispatch.ts.
+ */
+const alertMessage = (): SlackThreadMessage =>
+ message({
+ user: BOT_USER_ID,
+ botId: "B1",
+ isMe: true,
+ raw: {
+ attachments: [
+ {
+ color: "#e11d48",
+ fallback: "🚨 checkout p95 — Firing",
+ blocks: [
+ {
+ type: "header",
+ text: { type: "plain_text", text: "🚨 checkout p95 — Firing", emoji: true },
+ },
+ {
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: "*p95 latency* is *2.4s* — above 1s, measured over the last 5m.",
+ },
+ fields: [{ type: "mrkdwn", text: "*Severity*\n🔴 Critical" }],
+ },
+ {
+ type: "actions",
+ elements: [
+ {
+ type: "button",
+ text: { type: "plain_text", text: "Open in Maple", emoji: true },
+ url: "https://app.maple.dev/alerts/incidents/inc_1",
+ style: "primary",
+ },
+ ],
+ },
+ {
+ type: "context",
+ elements: [{ type: "mrkdwn", text: "🍁 Maple Alerts · Incident `inc_1`" }],
+ },
+ ],
+ },
+ ],
+ },
+ })
+
+describe("slackMessageContent", () => {
+ test("renders a Maple alert card from its attachment blocks", () => {
+ const { content, fromAttachment } = slackMessageContent(alertMessage())
+ expect(fromAttachment).toBe(true)
+ expect(content).toBe(
+ [
+ "🚨 checkout p95 — Firing",
+ "*p95 latency* is *2.4s* — above 1s, measured over the last 5m.",
+ "*Severity*\n🔴 Critical",
+ "Open in Maple: https://app.maple.dev/alerts/incidents/inc_1",
+ "🍁 Maple Alerts · Incident `inc_1`",
+ ].join("\n"),
+ )
+ })
+
+ test("prefers plain text over any block rendering of it", () => {
+ const { content, fromAttachment } = slackMessageContent(
+ message({
+ text: "p95 is up",
+ markdown: "p95 is up",
+ raw: { blocks: [{ type: "section", text: { type: "mrkdwn", text: "p95 is up" } }] },
+ }),
+ )
+ expect(content).toBe("p95 is up")
+ expect(fromAttachment).toBe(false)
+ })
+
+ test("renders top-level rich_text blocks as paragraphs, not one word per line", () => {
+ const { content } = slackMessageContent(
+ message({
+ raw: {
+ blocks: [
+ {
+ type: "rich_text",
+ elements: [
+ {
+ type: "rich_text_section",
+ elements: [
+ { type: "text", text: "see " },
+ {
+ type: "link",
+ url: "https://app.maple.dev/traces/t1",
+ text: "this trace",
+ },
+ { type: "text", text: " " },
+ { type: "user", user_id: "U2" },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ }),
+ )
+ expect(content).toBe("see this trace (https://app.maple.dev/traces/t1) <@U2>")
+ })
+
+ test("falls back to the attachment fallback line when it carries no blocks", () => {
+ const { content, fromAttachment } = slackMessageContent(
+ message({ raw: { attachments: [{ fallback: "🚨 checkout p95 — Firing" }] } }),
+ )
+ expect(content).toBe("🚨 checkout p95 — Firing")
+ expect(fromAttachment).toBe(true)
+ })
+
+ test("sweeps text out of block types it has no case for", () => {
+ const { content } = slackMessageContent(
+ message({
+ raw: { blocks: [{ type: "some_future_block", body: { type: "mrkdwn", text: "kept" } }] },
+ }),
+ )
+ expect(content).toBe("kept")
+ })
+
+ test("is empty rather than throwing on a message with nothing readable", () => {
+ expect(slackMessageContent(message({ raw: { blocks: "not-an-array" } })).content).toBe("")
+ })
+})
+
+describe("formatThreadContext", () => {
+ test("returns undefined for an empty thread", () => {
+ expect(formatThreadContext([])).toBeUndefined()
+ })
+
+ test("attributes an alert card to a bot, not to the agent", () => {
+ const rendered = formatThreadContext([alertMessage()], { botUserId: BOT_USER_ID })
+ expect(rendered).toContain("sender_type: bot")
+ expect(rendered).not.toContain("sender_type: agent")
+ expect(rendered).toContain("🚨 checkout p95 — Firing")
+ })
+
+ test("keeps the alert and the discussion under it in thread order", () => {
+ const rendered = formatThreadContext(
+ [
+ alertMessage(),
+ userMessage("is this the deploy from 10 minutes ago?", "1700000000.000200"),
+ message({
+ text: "Checking the last release now.",
+ markdown: "Checking the last release now.",
+ user: BOT_USER_ID,
+ botId: "B1",
+ isMe: true,
+ ts: "1700000000.000300",
+ threadTs: "1700000000.000100",
+ }),
+ ],
+ { botUserId: BOT_USER_ID },
+ )
+ expect(rendered?.startsWith("")).toBe(true)
+ expect(rendered?.endsWith("")).toBe(true)
+ expect(rendered?.match(/sender_type: (\w+)/gu)).toEqual([
+ "sender_type: bot",
+ "sender_type: user",
+ "sender_type: agent",
+ ])
+ })
+
+ test("labels a third-party bot as a bot, not as the agent", () => {
+ const github = message({
+ text: "PR #338 merged",
+ markdown: "PR #338 merged",
+ user: "UGH",
+ botId: "B9",
+ isMe: true,
+ })
+ expect(formatThreadContext([github], { botUserId: BOT_USER_ID })).toContain("sender_type: bot")
+ // Without a learned bot user id we can only fall back to eve's coarser
+ // "any bot is me".
+ expect(formatThreadContext([github])).toContain("sender_type: agent")
+ })
+})
+
+describe("loadThreadContext", () => {
+ const thread = (messages: readonly SlackThreadMessage[]) => {
+ const recentMessages: SlackThreadMessage[] = []
+ return {
+ recentMessages,
+ refresh: async () => {
+ recentMessages.length = 0
+ recentMessages.push(...messages)
+ },
+ }
+ }
+
+ test("includes the whole thread before the triggering message", async () => {
+ const trigger = userMessage("<@UBOT> why did this fire?", "1700000000.000300")
+ const context = await loadThreadContext(
+ thread([alertMessage(), userMessage("hm", "1700000000.000200"), trigger]),
+ { threadTs: trigger.threadTs, ts: trigger.ts },
+ { botUserId: BOT_USER_ID },
+ )
+ expect(context).toHaveLength(1)
+ // The alert survives — this is the bug: `since: "last-agent-reply"` used to
+ // cut context off after it, leaving the model nothing to answer from.
+ expect(context[0]).toContain("🚨 checkout p95 — Firing")
+ expect(context[0]).toContain("hm")
+ expect(context[0]).not.toContain("why did this fire?")
+ })
+
+ test("adds nothing for a thread root", async () => {
+ const root = userMessage("<@UBOT> hello", "1700000000.000100")
+ expect(await loadThreadContext(thread([root]), { threadTs: root.ts, ts: root.ts })).toEqual([])
+ })
+
+ test("degrades to no context when Slack fails, so the turn still dispatches", async () => {
+ const failing = {
+ recentMessages: [] as SlackThreadMessage[],
+ refresh: async () => {
+ throw new Error("slack down")
+ },
+ }
+ expect(
+ await loadThreadContext(failing, { threadTs: "1700000000.000100", ts: "1700000000.000300" }),
+ ).toEqual([])
+ })
+})
+
+describe("bot identity", () => {
+ test("learns the bot user id from a webhook envelope, per team", () => {
+ rememberBotUserId(
+ JSON.stringify({
+ type: "event_callback",
+ team_id: "T1",
+ authorizations: [{ user_id: BOT_USER_ID, is_bot: true }],
+ event: { type: "app_mention" },
+ }),
+ )
+ expect(botUserIdForTeam("T1")).toBe(BOT_USER_ID)
+ expect(botUserIdForTeam("T2")).toBeUndefined()
+ expect(botUserIdForTeam(undefined)).toBeUndefined()
+ })
+
+ test("ignores bodies it cannot read", () => {
+ rememberBotUserId("not json")
+ rememberBotUserId(JSON.stringify({ type: "event_callback", team_id: "T1" }))
+ expect(botUserIdForTeam("T1")).toBeUndefined()
+ })
+})
diff --git a/apps/slack-agent/agent/lib/thread-context.ts b/apps/slack-agent/agent/lib/thread-context.ts
new file mode 100644
index 000000000..afaa18f6d
--- /dev/null
+++ b/apps/slack-agent/agent/lib/thread-context.ts
@@ -0,0 +1,300 @@
+import { loadThreadContextMessages, type SlackThread, type SlackThreadMessage } from "eve/channels/slack"
+
+/**
+ * Background context for a turn: the Slack thread the bot was tagged in,
+ * rendered for the model.
+ *
+ * eve ships this as `slackChannel({ threadContext })`, but two of its choices
+ * made an @mention inside a Maple alert thread arrive with no usable context at
+ * all — the exact case where context matters most, since the user is replying
+ * to something the bot said rather than starting a topic:
+ *
+ * 1. **Content comes from `message.text` only.** Maple's alert notifications
+ * carry no top-level text: they are `chat.postMessage` with one colored
+ * attachment holding the Block Kit blocks
+ * (`apps/api/src/services/alerts/AlertDeliveryDispatch.ts` — the color bar
+ * has no block equivalent, and top-level text would render as a duplicate
+ * line above it). eve rendered them as an empty ``, so
+ * "why did this fire?" hung on nothing and the user had to paste a recap
+ * of the alert by hand.
+ * 2. **`since: "last-agent-reply"` treated the alert as the agent's own last
+ * reply** (eve's `isMe` is `bot_id !== undefined`, i.e. any bot), and cut
+ * the context off *after* it — dropping the alert and everything before it.
+ *
+ * So we render context ourselves: same `` /
+ * `` envelope eve uses (the model's view is unchanged in shape),
+ * but content falls back to blocks and attachments when `text` is empty, and
+ * the whole thread is included — the `thread-root` boundary. Full-thread is
+ * also what makes this survive a lost session: eve sessions are keyed
+ * `channelId:threadTs` and a Railway redeploy can drop the history that an
+ * incremental boundary silently assumes is still there.
+ *
+ * Wired in `#channels/slack.js` through `onAppMention` / `onDirectMessage`,
+ * which return these strings as the mention result's `context`.
+ *
+ * Two bounds worth knowing: every turn re-injects the transcript (so a long
+ * thread pays for its history once per mention), and eve's `thread.refresh()`
+ * fetches one oldest-first page of 50 replies, so past 50 the tail is the part
+ * that goes missing. Alert threads are short; revisit if that stops holding.
+ */
+
+/** Who posted a context message, from the model's point of view. */
+export type ThreadContextSenderType = "agent" | "bot" | "unknown" | "user"
+
+export interface ThreadContextOptions {
+ /**
+ * This workspace's bot user id (`#lib/bot-identity.js`). Distinguishes the
+ * agent's own replies from every other bot in the channel. When absent we
+ * fall back to eve's coarser "any bot is me".
+ */
+ readonly botUserId?: string | undefined
+}
+
+/**
+ * Loads the thread the triggering message belongs to and renders it as one
+ * context string, or `[]` when there is nothing to add (thread root, empty
+ * thread, or a Slack call that failed).
+ *
+ * Never throws: eve drops the whole mention when an `onAppMention` handler
+ * throws, and losing the reply entirely is far worse than losing its context.
+ */
+export async function loadThreadContext(
+ thread: Pick,
+ message: { readonly threadTs: string; readonly ts: string },
+ options: ThreadContextOptions = {},
+): Promise {
+ let messages: readonly SlackThreadMessage[]
+ try {
+ messages = await loadThreadContextMessages(thread, message, { since: "thread-root" })
+ } catch (error) {
+ console.warn("[slack-thread-context] Failed to load thread context; dispatching without it.", error)
+ return []
+ }
+ const rendered = formatThreadContext(messages, options)
+ return rendered === undefined ? [] : [rendered]
+}
+
+/**
+ * Renders thread messages as attributed background context, or `undefined`
+ * when there are none. Mirrors eve's `formatSlackThreadContext` envelope.
+ */
+export function formatThreadContext(
+ messages: readonly SlackThreadMessage[],
+ options: ThreadContextOptions = {},
+): string | undefined {
+ if (messages.length === 0) return undefined
+ return [
+ "",
+ ...messages.map((message) => formatThreadContextMessage(message, options)),
+ "",
+ ].join("\n")
+}
+
+function formatThreadContextMessage(message: SlackThreadMessage, options: ThreadContextOptions): string {
+ const { content, fromAttachment } = slackMessageContent(message)
+ const senderId = message.user ?? message.botId
+ return [
+ "",
+ `sender_type: ${senderType(message, fromAttachment, options)}`,
+ ...(senderId ? [`sender_id: ${senderId}`] : []),
+ `thread_ts: ${message.threadTs}`,
+ `message_ts: ${message.ts}`,
+ "",
+ content,
+ "",
+ "",
+ ].join("\n")
+}
+
+function senderType(
+ message: SlackThreadMessage,
+ fromAttachment: boolean,
+ options: ThreadContextOptions,
+): ThreadContextSenderType {
+ // An app notification posted through the same bot user — a Maple alert card —
+ // is not something the agent said. Labelling it `agent` would have the model
+ // believe it already reported the incident it is being asked about. The
+ // agent's own replies never use attachments (eve posts `markdown_text` or
+ // top-level blocks), so "content came from an attachment" separates them.
+ if (fromAttachment) return "bot"
+ if (options.botUserId !== undefined) {
+ if (message.user === options.botUserId) return "agent"
+ if (message.botId !== undefined) return "bot"
+ } else if (message.isMe) {
+ return "agent"
+ }
+ if (message.user !== undefined) return "user"
+ if (message.botId !== undefined) return "bot"
+ return "unknown"
+}
+
+/**
+ * The readable content of a thread message, and whether it had to be recovered
+ * from an attachment (Slack's legacy surface, which is where Block Kit cards
+ * with a color bar live — Maple alerts among them).
+ *
+ * Precedence is plain text → top-level blocks → attachments, so a message that
+ * has real text is never re-derived from its block rendering of that same text.
+ */
+export function slackMessageContent(message: SlackThreadMessage): {
+ readonly content: string
+ readonly fromAttachment: boolean
+} {
+ // `markdown` is eve's mrkdwn → GFM rendering of `text`.
+ const text = message.markdown.trim() || message.text.trim()
+ if (text) return { content: text, fromAttachment: false }
+
+ const blocks = blocksText(message.raw.blocks)
+ if (blocks) return { content: blocks, fromAttachment: false }
+
+ const attachments = attachmentsText(message.raw.attachments)
+ if (attachments) return { content: attachments, fromAttachment: true }
+
+ return { content: "", fromAttachment: false }
+}
+
+function attachmentsText(value: unknown): string {
+ return joinLines(
+ asArray(value).map((attachment) => {
+ if (!isRecord(attachment)) return ""
+ const blocks = blocksText(attachment.blocks)
+ if (blocks) return joinLines([str(attachment.title), blocks])
+ // Legacy attachments carry their body in `text`; `fallback` is the
+ // notification-preview one-liner and the last thing worth showing.
+ return joinLines([str(attachment.title), str(attachment.text)]) || str(attachment.fallback)
+ }),
+ )
+}
+
+function blocksText(value: unknown): string {
+ return joinLines(asArray(value).map(blockText))
+}
+
+function blockText(block: unknown): string {
+ if (!isRecord(block)) return ""
+ switch (block.type) {
+ case "divider":
+ return ""
+ case "header":
+ case "section":
+ return joinLines([textObject(block.text), ...asArray(block.fields).map(textObject)])
+ case "context":
+ case "actions":
+ return joinLines(asArray(block.elements).map(elementText))
+ case "rich_text":
+ return joinLines(asArray(block.elements).map(richTextBlockText))
+ case "markdown":
+ return str(block.text)
+ case "image":
+ return imageText(block)
+ default:
+ // Unknown / future block types: sweep every text object out of them
+ // rather than dropping content we simply don't have a case for.
+ return joinLines(collectTextObjects(block))
+ }
+}
+
+function elementText(element: unknown): string {
+ if (!isRecord(element)) return ""
+ if (element.type === "image") return imageText(element)
+ const label = textObject(element.text)
+ // Buttons: the label alone ("Open in Maple") says nothing the model can act
+ // on — the link is the payload.
+ const url = str(element.url)
+ if (label && url) return `${label}: ${url}`
+ return label || url || joinLines(collectTextObjects(element))
+}
+
+function richTextBlockText(element: unknown): string {
+ if (!isRecord(element)) return ""
+ switch (element.type) {
+ case "rich_text_list":
+ return joinLines(asArray(element.elements).map((item) => `- ${richTextBlockText(item)}`))
+ case "rich_text_quote":
+ return joinLines(
+ inlineText(element.elements)
+ .split("\n")
+ .map((line) => `> ${line}`),
+ )
+ default:
+ // rich_text_section / rich_text_preformatted, and anything Slack adds.
+ return inlineText(element.elements)
+ }
+}
+
+/** Inline rich-text runs concatenate — they are one paragraph, not a list. */
+function inlineText(value: unknown): string {
+ return asArray(value)
+ .map((element) => {
+ if (!isRecord(element)) return ""
+ switch (element.type) {
+ case "text":
+ // Not `str()`: the whitespace between runs is the sentence's own
+ // spacing, and trimming each run glues the words together.
+ return typeof element.text === "string" ? element.text : ""
+ case "link": {
+ const label = str(element.text)
+ const url = str(element.url)
+ return label && label !== url ? `${label} (${url})` : url
+ }
+ case "user":
+ return `<@${str(element.user_id)}>`
+ case "usergroup":
+ return ``
+ case "channel":
+ return `<#${str(element.channel_id)}>`
+ case "emoji":
+ return `:${str(element.name)}:`
+ case "broadcast":
+ return ``
+ default:
+ return ""
+ }
+ })
+ .join("")
+ .trim()
+}
+
+function imageText(block: Record): string {
+ const alt = str(block.alt_text) || textObject(block.title)
+ return alt ? `[image: ${alt}]` : ""
+}
+
+/** The text of a Slack `{ type: "mrkdwn" | "plain_text", text }` object. */
+function textObject(value: unknown): string {
+ return isRecord(value) ? str(value.text) : ""
+}
+
+function collectTextObjects(value: unknown, out: string[] = []): string[] {
+ if (Array.isArray(value)) {
+ for (const item of value) collectTextObjects(item, out)
+ return out
+ }
+ if (!isRecord(value)) return out
+ if ((value.type === "mrkdwn" || value.type === "plain_text") && typeof value.text === "string") {
+ const text = value.text.trim()
+ if (text) out.push(text)
+ return out
+ }
+ for (const item of Object.values(value)) collectTextObjects(item, out)
+ return out
+}
+
+function joinLines(parts: readonly string[]): string {
+ return parts
+ .map((part) => part.trim())
+ .filter((part) => part.length > 0)
+ .join("\n")
+}
+
+function str(value: unknown): string {
+ return typeof value === "string" ? value.trim() : ""
+}
+
+function asArray(value: unknown): readonly unknown[] {
+ return Array.isArray(value) ? value : []
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
diff --git a/apps/slack-agent/agent/lib/thread-follow-up.ts b/apps/slack-agent/agent/lib/thread-follow-up.ts
index 7084151c1..68906a30e 100644
--- a/apps/slack-agent/agent/lib/thread-follow-up.ts
+++ b/apps/slack-agent/agent/lib/thread-follow-up.ts
@@ -1,3 +1,4 @@
+import { botUserIdFromEnvelope } from "./bot-identity.js"
import { resolveBotToken } from "./maple.js"
import { createTtlCache } from "./ttl-cache.js"
@@ -217,21 +218,6 @@ function parseFollowUpCandidate(rawBody: string): FollowUpCandidate | null {
}
}
-/**
- * The event envelope's `authorizations` names the app's bot user — no
- * `auth.test` round-trip needed.
- */
-function botUserIdFromEnvelope(envelope: Record): string | null {
- const authorizations = envelope.authorizations
- if (!Array.isArray(authorizations)) return null
- for (const auth of authorizations) {
- if (isRecord(auth) && typeof auth.user_id === "string" && auth.user_id.length > 0) {
- return auth.user_id
- }
- }
- return null
-}
-
async function isBotEngagedInThread(
candidate: FollowUpCandidate,
deps: ThreadFollowUpDeps,