Skip to content
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
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Read Slack messages, threads, and channel history from any URL or channel name
- Search Slack messages and files with filters for channel, user, date, and content type
- Send, edit, delete Slack messages and add/remove emoji reactions programmatically
- Upload files to channels and DMs with optional comments
- Auto-download Slack file attachments (snippets, images, files) to local paths for AI agent consumption
- Token-efficient compact JSON output so LLMs can consume Slack data cheaply
- Zero-config auth: auto-detects Slack Desktop credentials on macOS, Windows, and Linux — with Chrome, Brave, and Firefox fallbacks
Expand Down
14 changes: 13 additions & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ description: |
- Discovering and running Slack workflows
- Managing saved-for-later messages (Later tab)
- Viewing all unread messages (inbox/unreads view)
Triggers: "slack message", "slack thread", "slack URL", "slack link", "read slack", "reply on slack", "search slack", "channel history", "recent messages", "channel messages", "latest messages", "mark as read", "mark read", "slack later", "saved for later", "save for later", "slack unreads", "slack inbox", "unread slack"
Triggers: "slack message", "slack thread", "slack URL", "slack link", "read slack", "reply on slack", "search slack", "channel history", "recent messages", "channel messages", "latest messages", "mark as read", "mark read", "slack later", "saved for later", "save for later", "slack unreads", "slack inbox", "unread slack", "upload file", "slack file upload", "send file slack"
---

# Slack automation with `agent-slack`
Expand Down Expand Up @@ -165,6 +165,18 @@ Attach options for `message send`:

`message send` returns `channel_id` plus the posted `ts` and a `permalink` (for non-attachment sends). `thread_ts` appears only when replying in a thread.

## Upload files

Upload files directly to a channel, DM, or thread without sending a text message:

```bash
agent-slack file upload "general" ./report.md
agent-slack file upload "general" ./report.md --comment "Coverage report"
agent-slack file upload U08GDK5PBLG ./data.csv
agent-slack file upload "general" ./report.md --attach ./chart.png
agent-slack file upload "general" ./report.md --thread-ts "1770165109.628379"
```

Mentions: just write `@U05BRPTKL6A`, `@here`, `@channel`, or `@everyone` — the CLI converts them to real Slack mention tokens and escapes literal `&`/`<`/`>` in your text. You don't need to wrap IDs yourself.

## List channels + create/invite users
Expand Down
12 changes: 12 additions & 0 deletions skills/agent-slack/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ Common options:
- `--resolve-users` (attach resolved user profiles in `referenced_users`; applies to `search messages` / `search all`)
- `--refresh-users` (implies `--resolve-users` and forces a cache refresh)

## File

- `agent-slack file upload <target> <path>`
- Uploads one or more files to a channel, DM, or thread
- `<target>`: Slack message URL, `#channel`/channel ID, or user ID
- `<path>`: Local file path to upload
- Options:
- `--workspace <url-or-unique-substring>` (needed for channel _names_ across multiple workspaces)
- `--thread-ts <seconds>.<micros>` (optional)
- `--comment <text>` (initial comment to include with upload)
- `--attach <path>` (repeatable; additional file paths to upload)

## Canvas

- `agent-slack canvas get <canvas-url-or-id>`
Expand Down
100 changes: 100 additions & 0 deletions src/cli/file-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { CliContext } from "./context.ts";
import { parseMsgTarget } from "./targets.ts";
import { resolveChannelId, openDmChannel } from "../slack/channels.ts";
import { uploadLocalFileToSlack } from "../slack/upload.ts";
import { fetchMessage } from "../slack/messages.ts";
import { warnOnTruncatedSlackUrl } from "./message-url-warning.ts";

export async function uploadFile(input: {
ctx: CliContext;
targetInput: string;
filePaths: string[];
options: { workspace?: string; threadTs?: string; comment?: string };
}): Promise<Record<string, unknown>> {
const target = parseMsgTarget(String(input.targetInput));
const dedupedPaths = [...new Set(input.filePaths.map((p) => p.trim()).filter(Boolean))];

if (target.kind === "url") {
const { ref } = target;
warnOnTruncatedSlackUrl(ref);
return await input.ctx.withAutoRefresh({
workspaceUrl: ref.workspace_url,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(ref.workspace_url);
const msg = await fetchMessage(client, { ref });
const threadTs = msg.thread_ts ?? msg.ts;
return await uploadFiles({
client,
channelId: ref.channel_id,
filePaths: dedupedPaths,
threadTs,
comment: input.options.comment,
});
},
});
}

if (target.kind === "user") {
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const dmChannelId = await openDmChannel(client, target.userId);
return await uploadFiles({
client,
channelId: dmChannelId,
filePaths: dedupedPaths,
threadTs: input.options.threadTs,
comment: input.options.comment,
});
},
});
}

const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
await input.ctx.assertWorkspaceSpecifiedForChannelNames({
workspaceUrl,
channels: [String(target.channel)],
});
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const channelId = await resolveChannelId(client, String(target.channel));
return await uploadFiles({
client,
channelId,
filePaths: dedupedPaths,
threadTs: input.options.threadTs,
comment: input.options.comment,
});
},
});
}

async function uploadFiles(input: {
client: Parameters<typeof uploadLocalFileToSlack>[0]["client"];
channelId: string;
filePaths: string[];
threadTs?: string;
comment?: string;
}): Promise<Record<string, unknown>> {
let initialComment = input.comment;
for (const filePath of input.filePaths) {
await uploadLocalFileToSlack({
client: input.client,
channelId: input.channelId,
filePath,
threadTs: input.threadTs,
initialComment,
});
initialComment = undefined;
}

return {
ok: true,
channel_id: input.channelId,
files_uploaded: input.filePaths.length,
};
}
59 changes: 59 additions & 0 deletions src/cli/file-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Command } from "commander";
import type { CliContext } from "./context.ts";
import { uploadFile } from "./file-actions.ts";

function collectOptionValue(value: string, previous: string[] = []): string[] {
return [...previous, value];
}

export function registerFileCommand(input: { program: Command; ctx: CliContext }): void {
const fileCmd = input.program.command("file").description("Upload and manage Slack files");

fileCmd
.command("upload")
.description("Upload one or more files to a channel or DM")
.argument("<target>", "Slack message URL, #name/name, channel id, or user id")
.argument(
"<path>",
"Local file path to upload (repeatable via --attach)",
collectOptionValue,
[],
)
.option(
"--workspace <url>",
"Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)",
)
.option("--thread-ts <ts>", "Thread root ts to upload into (optional)")
.option("--comment <text>", "Initial comment to include with the upload")
.option(
"--attach <path>",
"Additional file paths to upload (repeatable)",
collectOptionValue,
[],
)
.action(async (...args) => {
const [targetInput, paths, options] = args as [
string,
string[],
{ workspace?: string; threadTs?: string; comment?: string; attach?: string[] },
];
const allPaths = [...paths, ...(options.attach ?? [])];
if (allPaths.length === 0) {
console.error("Error: at least one file path is required.");
process.exitCode = 1;
return;
}
try {
const payload = await uploadFile({
ctx: input.ctx,
targetInput,
filePaths: allPaths,
options,
});
console.log(JSON.stringify(payload, null, 2));
} catch (err: unknown) {
console.error(input.ctx.errorMessage(err));
process.exitCode = 1;
}
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerUnreadsCommand } from "./cli/unreads-command.ts";
import { registerUpdateCommand } from "./cli/update-command.ts";
import { registerUserCommand } from "./cli/user-command.ts";
import { registerChannelCommand } from "./cli/channel-command.ts";
import { registerFileCommand } from "./cli/file-command.ts";
import { registerWorkflowCommand } from "./cli/workflow-command.ts";
import { backgroundUpdateCheck } from "./lib/update.ts";

Expand All @@ -30,6 +31,7 @@ registerUnreadsCommand({ program, ctx });
registerUpdateCommand({ program });
registerUserCommand({ program, ctx });
registerChannelCommand({ program, ctx });
registerFileCommand({ program, ctx });
registerWorkflowCommand({ program, ctx });

program.parse(process.argv);
Expand Down
Loading