Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions apps/bot/src/lib/ai/tools/canvas.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we split this into multiple files, canvas/read.ts, canvas/write.ts, etc. Share schema w/utils.ts or smth

Original file line number Diff line number Diff line change
@@ -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',
})
);
Comment on lines +56 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Slack files.list API types parameter accepted values for canvases

💡 Result:

To retrieve Slack canvases using the files.list API, you use the "canvas" value for the types parameter [1]. An example API call would look like: https://slack.com/api/files.list?types=canvas [1]. Slack treats standalone canvases as file objects [2], allowing them to be indexed and listed via the files.list endpoint when this specific filter is applied [1].

Citations:


Use types: 'canvas' here. Slack’s files.list canvas filter is singular, so canvases won’t target canvas files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/bot/src/lib/ai/tools/canvas.ts` around lines 56 - 61, The Slack
files.list request in the canvas tool is using the wrong filter value, so it
won’t match canvas files. Update the `canvas.ts` logic in the code that calls
`slack.webClient.apiCall('files.list', ...)` to use the singular `types:
'canvas'` instead of `canvases`, keeping the rest of the `filesListSchema.parse`
flow unchanged.

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}` },
});
Comment on lines +259 to +261

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a timeout to the canvas download fetch.

This fetch runs on the request path with no timeout, so a slow/hung Slack file endpoint can block the tool execution indefinitely. Use AbortSignal.timeout(...) (or an AbortController) to bound it.

🛡️ Proposed fix
-        const response = await fetch(info.file.url_private_download, {
-          headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
-        });
+        const response = await fetch(info.file.url_private_download, {
+          headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
+          signal: AbortSignal.timeout(15_000),
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(info.file.url_private_download, {
headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
});
const response = await fetch(info.file.url_private_download, {
headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
signal: AbortSignal.timeout(15_000),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/bot/src/lib/ai/tools/canvas.ts` around lines 259 - 261, Bound the Slack
file download in canvas.ts so the request-path fetch cannot hang indefinitely;
update the fetch call in the canvas download logic to use a timeout via
AbortSignal.timeout(...) or an AbortController, and make sure the timeout is
applied around the existing info.file.url_private_download request with the
Authorization header. If the timeout triggers, handle the abort cleanly so the
tool can fail fast instead of blocking execution.

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 };
}
},
});
}
10 changes: 10 additions & 0 deletions apps/bot/src/lib/ai/toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +56,10 @@ export function buildTools({
}),
getChannelInfo: getChannelInfoTool({ bot, currentThreadId: thread.id }),
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 }),
Expand Down
2 changes: 2 additions & 0 deletions slack-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"commands",
"app_mentions:read",
"assistant:write",
"canvases:read",
"canvases:write",
"channels:history",
"channels:join",
"channels:manage",
Expand Down