From ae970a8fdec4068a12adffe50e25628492663c40 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Jun 2026 11:30:17 +0000 Subject: [PATCH] feat: add Slack canvas tools to the agent Add canvasRead, canvasWrite, canvasList, and canvasDelete agent tools so the bot can create, read, list, and delete Slack canvases (rich, persistent docs) for meeting notes, decisions, specs, and runbooks. Tools use slack.webClient.apiCall and follow the existing tool conventions ({ success, error?, summary? }, errorMessage() + logger.warn on failure). Declares the canvases:read / canvases:write scopes in slack-manifest.json. Co-Authored-By: Claude Opus 4.8 --- apps/bot/src/lib/ai/tools/canvas.ts | 281 ++++++++++++++++++++++++++++ apps/bot/src/lib/ai/toolset.ts | 10 + slack-manifest.json | 2 + 3 files changed, 293 insertions(+) create mode 100644 apps/bot/src/lib/ai/tools/canvas.ts diff --git a/apps/bot/src/lib/ai/tools/canvas.ts b/apps/bot/src/lib/ai/tools/canvas.ts new file mode 100644 index 00000000..26208c03 --- /dev/null +++ b/apps/bot/src/lib/ai/tools/canvas.ts @@ -0,0 +1,281 @@ +import { errorMessage } from '@/lib/utils/error'; +import { tool } from 'ai'; +import type { Thread } from 'chat'; +import { z } from 'zod'; +import { env } from '@/env'; +import { slack } from '@/lib/chat'; +import logger from '@/lib/logger'; + +const canvasResponseSchema = z.looseObject({ + canvas_id: z.string().optional(), + error: z.string().optional(), + ok: z.boolean(), +}); + +const filesInfoSchema = z.looseObject({ + error: z.string().optional(), + file: z + .looseObject({ + id: z.string().optional(), + title: z.string().optional(), + url_private_download: z.string().optional(), + }) + .optional(), + ok: z.boolean(), +}); + +const filesListSchema = z.looseObject({ + error: z.string().optional(), + files: z + .array( + z.looseObject({ id: z.string().optional(), title: z.string().optional() }) + ) + .optional(), + ok: z.boolean(), +}); + +function channelIdFromThread(thread: Thread): string | undefined { + const [platform, channelId] = thread.id.split(':'); + return platform === 'slack' ? channelId : undefined; +} + +export function canvasListTool({ thread }: { thread: Thread }) { + return tool({ + description: + 'List existing Slack canvases in the current channel so you can discover one to read or edit. Returns canvas ids and titles.', + inputSchema: z.object({}), + execute: async () => { + try { + const channelId = channelIdFromThread(thread); + if (!channelId) { + return { + error: 'Could not resolve a Slack channel for this thread.', + success: false, + }; + } + const result = filesListSchema.parse( + await slack.webClient.apiCall('files.list', { + channel: channelId, + types: 'canvases', + }) + ); + if (!result.ok) { + return { + error: `Could not list canvases: ${result.error}`, + success: false, + }; + } + const canvases = (result.files ?? []).map((file) => ({ + canvasId: file.id, + title: file.title, + })); + return { + canvases, + success: true, + summary: `Found ${canvases.length} canvas${canvases.length === 1 ? '' : 'es'} in this channel.`, + }; + } catch (error) { + logger.warn({ error: errorMessage(error) }, '[canvasList] failed'); + return { error: errorMessage(error), success: false }; + } + }, + }); +} + +export function canvasWriteTool({ thread }: { thread: Thread }) { + return tool({ + description: + 'Create or edit a Slack canvas (a rich, persistent document). Use to capture meeting notes, decisions, specs, runbooks, or any long-lived structured content. Prefer this over long messages for content the team will return to.', + inputSchema: z.object({ + mode: z + .enum(['create-channel', 'create-standalone', 'edit']) + .describe( + 'create-channel: attach a canvas to the current channel. create-standalone: a free-floating canvas. edit: change an existing canvas (requires canvasId).' + ), + title: z + .string() + .min(1) + .max(255) + .optional() + .describe('Title for a standalone canvas.'), + markdown: z + .string() + .min(1) + .max(100_000) + .describe('Canvas body as Slack-flavored markdown.'), + canvasId: z + .string() + .min(1) + .optional() + .describe('Canvas id to edit (required when mode is "edit").'), + editOperation: z + .enum(['replace', 'insert_at_end']) + .default('insert_at_end') + .describe('How to apply markdown when editing an existing canvas.'), + }), + execute: async ({ mode, title, markdown, canvasId, editOperation }) => { + try { + const documentContent = { markdown, type: 'markdown' as const }; + + if (mode === 'edit') { + if (!canvasId) { + return { + error: 'canvasId is required to edit a canvas.', + success: false, + }; + } + const result = canvasResponseSchema.parse( + await slack.webClient.apiCall('canvases.edit', { + canvas_id: canvasId, + changes: [ + editOperation === 'replace' + ? { document_content: documentContent, operation: 'replace' } + : { + document_content: documentContent, + operation: 'insert_at_end', + }, + ], + }) + ); + if (!result.ok) { + return { + error: `Canvas edit failed: ${result.error}`, + success: false, + }; + } + return { + canvasId, + success: true, + summary: `Edited canvas ${canvasId}.`, + }; + } + + if (mode === 'create-channel') { + const channelId = channelIdFromThread(thread); + if (!channelId) { + return { + error: 'Could not resolve a Slack channel for this thread.', + success: false, + }; + } + const result = canvasResponseSchema.parse( + await slack.webClient.apiCall('conversations.canvases.create', { + channel_id: channelId, + document_content: documentContent, + }) + ); + if (!result.ok) { + return { + error: `Channel canvas creation failed: ${result.error}`, + success: false, + }; + } + return { + canvasId: result.canvas_id, + success: true, + summary: 'Created a canvas in this channel.', + }; + } + + const result = canvasResponseSchema.parse( + await slack.webClient.apiCall('canvases.create', { + document_content: documentContent, + ...(title && { title }), + }) + ); + if (!result.ok) { + return { + error: `Canvas creation failed: ${result.error}`, + success: false, + }; + } + return { + canvasId: result.canvas_id, + success: true, + summary: `Created canvas${title ? ` "${title}"` : ''}.`, + }; + } catch (error) { + logger.warn({ error: errorMessage(error) }, '[canvasWrite] failed'); + return { error: errorMessage(error), success: false }; + } + }, + }); +} + +export function canvasDeleteTool() { + return tool({ + description: + 'Delete a Slack canvas by its canvas/file id. This is permanent — only use when explicitly asked to remove a canvas.', + inputSchema: z.object({ + canvasId: z + .string() + .min(1) + .describe('Canvas (file) id to delete, e.g. F0123ABC.'), + }), + execute: async ({ canvasId }) => { + try { + const result = canvasResponseSchema.parse( + await slack.webClient.apiCall('canvases.delete', { + canvas_id: canvasId, + }) + ); + if (!result.ok) { + return { + error: `Canvas delete failed: ${result.error}`, + success: false, + }; + } + return { + canvasId, + success: true, + summary: `Deleted canvas ${canvasId}.`, + }; + } catch (error) { + logger.warn({ error: errorMessage(error) }, '[canvasDelete] failed'); + return { error: errorMessage(error), success: false }; + } + }, + }); +} + +export function canvasReadTool() { + return tool({ + description: + 'Read the contents of an existing Slack canvas by its canvas/file id. Use to review notes or specs before editing or answering questions about them.', + inputSchema: z.object({ + canvasId: z.string().min(1).describe('Canvas (file) id, e.g. F0123ABC.'), + }), + execute: async ({ canvasId }) => { + try { + const info = filesInfoSchema.parse( + await slack.webClient.apiCall('files.info', { file: canvasId }) + ); + if (!(info.ok && info.file?.url_private_download)) { + return { + error: `Could not load canvas: ${info.error ?? 'no downloadable content'}`, + success: false, + }; + } + const response = await fetch(info.file.url_private_download, { + headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` }, + }); + if (!response.ok) { + return { + error: `Failed to download canvas content: ${response.status}`, + success: false, + }; + } + const content = await response.text(); + return { + canvasId, + content, + success: true, + title: info.file.title, + }; + } catch (error) { + logger.warn({ error: errorMessage(error) }, '[canvasRead] failed'); + return { error: errorMessage(error), success: false }; + } + }, + }); +} diff --git a/apps/bot/src/lib/ai/toolset.ts b/apps/bot/src/lib/ai/toolset.ts index 5bc90d5d..b5eae780 100644 --- a/apps/bot/src/lib/ai/toolset.ts +++ b/apps/bot/src/lib/ai/toolset.ts @@ -4,6 +4,12 @@ import type { ToolSet } from 'ai'; import type { Chat, Message, Thread } from 'chat'; import { createChatTools } from 'chat/ai'; import { env } from '@/env'; +import { + canvasDeleteTool, + canvasListTool, + canvasReadTool, + canvasWriteTool, +} from './tools/canvas'; import { generateImageTool } from './tools/generate-image'; import { getChannelInfoTool } from './tools/get-channel-info'; import { getFileTool } from './tools/get-file'; @@ -52,6 +58,10 @@ export function buildTools({ getChannelInfo: getChannelInfoTool({ bot, currentThreadId: thread.id }), ...(sendDirectMessage && { sendDirectMessage }), mermaid: mermaidTool({ thread }), + canvasRead: canvasReadTool(), + canvasWrite: canvasWriteTool({ thread }), + canvasList: canvasListTool({ thread }), + canvasDelete: canvasDeleteTool(), scheduleReminder: scheduleReminderTool({ message }), searchSlack: searchSlack({ message }), searchWeb: searchWeb({ apiKey: env.EXA_API_KEY }), diff --git a/slack-manifest.json b/slack-manifest.json index d510d243..cd83c197 100644 --- a/slack-manifest.json +++ b/slack-manifest.json @@ -32,6 +32,8 @@ "commands", "app_mentions:read", "assistant:write", + "canvases:read", + "canvases:write", "channels:history", "channels:join", "channels:manage",