Skip to content
Open
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
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ agent-slack message draft create "#general" "Latest numbers" --attach ./q3.png -
agent-slack message draft update "DR_ID" "Latest numbers" --attach ./appendix.pdf
```

Native draft listings read at most 100 records. Their JSON includes `"has_more": true` when Slack reports additional drafts; the internal API does not expose a cursor for retrieving them.

### Safe mode (enforced human-in-the-loop)

Skill instructions like "always use `draft`, never `send`" are guidance an agent can ignore. Safe mode enforces it at the tool level — useful when an AI agent has access to `agent-slack` and you want a guarantee that nothing posts without human review.
Expand Down Expand Up @@ -304,10 +306,10 @@ agent-slack message delete "#general" --workspace "myteam" --ts "1770165109.6283
Send options for `message send`:

- `--attach <path>` upload a local file (repeatable; `<text>` is optional when attaching files)
- `--blocks <path>` send raw [Block Kit](https://docs.slack.dev/block-kit/) blocks from a JSON file (or `-` for stdin). Bypasses the automatic markdown-to-rich-text conversion, unlocking header/divider/section/table blocks and other structured layouts. Cannot be combined with `--attach`.
- `--blocks <path>` send raw [Block Kit](https://docs.slack.dev/block-kit/) blocks from a JSON file (or `-` for stdin). Bypasses the automatic markdown-to-rich-text conversion, unlocking header/divider/section/table blocks and other structured layouts. Browser-auth scheduled sends accept only non-empty top-level `rich_text` blocks because Slack Desktop strips or tombstones other native-draft content. Cannot be combined with `--attach`.
- `--reply-broadcast` when replying in a thread, also post the reply to the parent channel (Slack's "Also send to #channel" checkbox). For channel targets, pair with `--thread-ts`; for URL targets, the thread context is derived from the message. Not supported for DM targets; cannot be combined with `--attach`.
- `--no-unfurl` suppress Slack link and media previews. Also available on `message compose`; cannot be combined with `--attach`.
- `--schedule <time>` schedule delivery at an ISO 8601 timestamp with explicit timezone (for example `YYYY-MM-DDTHH:mm:ss-07:00`) or a Unix timestamp. The timestamp must be in the future and within Slack's 120-day scheduled-send limit. Works with `--blocks`, `--thread-ts`, `--reply-broadcast`, and `--no-unfurl`; cannot be combined with `--attach`.
- `--no-unfurl` suppress Slack link and media previews. Also available on `message compose`; unavailable for browser-auth scheduled sends and cannot be combined with `--attach`.
- `--schedule <time>` schedule delivery at an ISO 8601 timestamp with explicit timezone (for example `YYYY-MM-DDTHH:mm:ss-07:00`) or a Unix timestamp. The timestamp must be in the future and within Slack's 120-day scheduled-send limit. Works with `--blocks`, `--thread-ts`, and `--reply-broadcast`, subject to the browser-auth restrictions above; cannot be combined with `--attach`.
- `--schedule-in <duration>` schedule delivery after a duration or simple future phrase (`30m`, `3h`, `2d`, `tomorrow 9am`, `monday 9am`; phrases use your local timezone). Mutually exclusive with `--schedule`; cannot be combined with `--attach`.

Upload files through `message send`:
Expand All @@ -323,7 +325,7 @@ agent-slack message send "#general" "Decision: shipping v2 today" \
--thread-ts "1770160000.000001" --reply-broadcast
```

Scheduled sends use Slack's server-side scheduled message queue:
Scheduled sends use Slack's own server-side scheduling. Standard tokens call the public `chat.scheduleMessage` API and return a `Q...` ID. Browser credentials use Slack's native `drafts.create` scheduling field and return a `Dr...` ID because the public method rejects browser-session tokens. On Enterprise Grid, agent-slack resolves channel names in the selected workspace, then verifies and routes the native-draft call through that workspace's organization credential.

```bash
# Absolute time with explicit timezone; replace with a future value within 120 days
Expand All @@ -333,7 +335,7 @@ agent-slack message send "#general" "Reminder: deploy starts soon." \
# Relative / natural future time
agent-slack message send "#general" "Monday launch checklist" --schedule-in "monday 9am"

# Scheduled thread reply with a Block Kit payload
# Scheduled thread reply with a Block Kit payload (standard tokens; browser auth requires rich_text-only blocks)
agent-slack message send "#general" "fallback text" \
--thread-ts "1770160000.000001" --blocks /tmp/blocks.json --schedule-in "3h"
```
Expand All @@ -346,6 +348,8 @@ agent-slack message scheduled list --channel "#general" --limit 25
agent-slack message scheduled cancel "Q1234ABCD" --channel "C12345678"
```

With browser auth, `message scheduled list` reads at most 100 native drafts because Slack's internal `drafts.list` response exposes `has_more` but no pagination cursor. The JSON output includes `"has_more": true` when the result may be incomplete. `--cursor` applies only to standard-token scheduling. Cancel browser-auth schedules with the returned `Dr...` ID and the same required channel argument.

Example — post a message with a native Slack table block:

```bash
Expand Down Expand Up @@ -375,7 +379,7 @@ agent-slack message send "#alerts-staging" --blocks /tmp/blocks.json

When `--blocks` is used, the positional `<text>` argument (if provided) is still sent as the message's `text` fallback (for notifications and unfurls).

`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. Scheduled sends return `scheduled_message_id` and `post_at` instead of `ts`/`permalink`.
`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. Scheduled sends return `scheduled_message_id` (`Q...` for standard tokens or `Dr...` for browser auth) and `post_at` instead of `ts`/`permalink`.

### List, create, and invite channels

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,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, schedule, edit, delete Slack messages, upload local files with `message send --attach` (also on `message draft create/update`), and add/remove emoji reactions programmatically
- Send, schedule, edit, delete Slack messages, upload local files with `message send --attach` (also on `message draft create/update`), and add/remove emoji reactions programmatically; scheduling supports standard tokens and browser-session credentials
- 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
4 changes: 2 additions & 2 deletions skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ If a capability named here is absent from installed help, report version skew in
4. Use output limits such as `--limit`, `--max-body-chars`, and `--max-content-chars` to avoid unnecessary context.
5. For a requested write, execute only the requested mutation and verify the resulting JSON metadata.

For scheduled writes, prefer `--schedule` with an ISO 8601 timestamp and explicit offset when timezone matters. Named `--schedule-in` phrases use the executing environment's local timezone; confirm that it matches the user's intent.
For scheduled writes, prefer `--schedule` with an ISO 8601 timestamp and explicit offset when timezone matters. Named `--schedule-in` phrases use the executing environment's local timezone; confirm that it matches the user's intent. Standard tokens use `chat.scheduleMessage`; browser auth uses a Slack-native scheduled draft and returns a `Dr...` ID. Browser-auth schedules accept only non-empty `rich_text` blocks and do not support `--no-unfurl`.

Named `later remind --in` values such as `tomorrow` or `monday` also use the executing environment's local timezone at 9:00. Confirm that timezone or pass an explicit Unix timestamp.

Use `--no-unfurl` with `message send` or `message compose` when the user wants Slack link and media previews suppressed. It cannot be combined with `message send --attach`.

Ordinary `message send` and `message edit` calls auto-convert lists. `message send --blocks` and `message edit --blocks` use supplied Block Kit blocks, while `message send --attach` sends its initial comment without automatic list conversion. Inside auto-converted lists, use Slack's `<URL|label>` syntax because CommonMark `[label](URL)` links are not converted into labeled link elements.

Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. `create` and `update` accept repeatable `--attach <path>`; on `update` the files are added to the draft's existing attachments rather than replacing them. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd).
Slack-native drafts (`message draft list|create|update|delete`) manage drafts that appear in the user's Slack client; `create` posts nothing. `create` and `update` accept repeatable `--attach <path>`; on `update` the files are added to the draft's existing attachments rather than replacing them. They use undocumented session endpoints and require browser-style auth (xoxc/xoxd). Listings may include `has_more: true`; Slack exposes no cursor for the remaining native drafts.

`canvas edit` uses Slack's public `canvases.edit` API and applies exactly one operation per call. The
default `replace` operation replaces the whole canvas; section-targeted inserts/replacements and
Expand Down
2 changes: 1 addition & 1 deletion skills/agent-slack/references/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Slack data commands print JSON to stdout. Help, update, and some authentication

`message get` returns one message and an optional thread summary. `message list` returns chronological messages; in thread mode this includes the root and all replies.

Immediate non-attachment sends return `ts` and usually a `permalink`. Attachment sends return neither; scheduled sends return `scheduled_message_id` and `post_at` instead.
Immediate non-attachment sends return `ts` and usually a `permalink`. Attachment sends return neither; scheduled sends return `scheduled_message_id` (`Q...` with standard tokens or `Dr...` with browser auth) and `post_at` instead. Native draft and browser-auth scheduled listings may return `has_more: true`; Slack's native drafts API does not expose a cursor for the remaining results.

`canvas create` returns `canvas: { id, title?, channel_id? }`. `canvas get` returns `canvas: { id, title?, markdown }`.
`canvas edit` returns `ok: true` and `canvas: { id, operation }` after Slack accepts the change.
Expand Down
51 changes: 46 additions & 5 deletions src/cli/context-client-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
import { resolveWorkspaceSelector } from "./workspace-selector.ts";
import { SlackApiClient, type SlackAuth } from "../slack/client.ts";

export type ClientResolutionOptions = {
excludeAuth?: SlackAuth;
};

export function normalizeUrl(u: string): string {
const url = new URL(u);
return `${url.protocol}//${url.host}`;
Expand Down Expand Up @@ -40,7 +44,10 @@ function pickAuthFromEnv(): SlackAuth | null {
return { auth_type: "standard", token };
}

export async function getClientForWorkspace(workspaceUrl?: string): Promise<{
export async function getClientForWorkspace(
workspaceUrl?: string,
options: ClientResolutionOptions = {},
): Promise<{
client: SlackApiClient;
auth: SlackAuth;
workspace_url?: string;
Expand All @@ -53,9 +60,9 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{
const creds = await loadCredentials();
const resolved = resolveWorkspaceSelector(creds.workspaces, selector);
if (resolved.ambiguous.length > 0) {
const options = resolved.ambiguous.map((w) => w.workspace_url).join(", ");
const matches = resolved.ambiguous.map((w) => w.workspace_url).join(", ");
throw new Error(
`Workspace selector "${selector}" is ambiguous. Matches: ${options}. Pass a more specific selector or full workspace URL.`,
`Workspace selector "${selector}" is ambiguous. Matches: ${matches}. Pass a more specific selector or full workspace URL.`,
);
}
if (resolved.match) {
Expand All @@ -66,8 +73,12 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{
}

const env = pickAuthFromEnv();
if (env) {
const envWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim();
const envWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim();
if (
env &&
!sameAuth(env, options.excludeAuth) &&
environmentAuthMatchesSelector({ env, envWorkspaceUrl, selector, resolvedWorkspaceUrl })
) {
const urlForBrowser = resolvedWorkspaceUrl || envWorkspaceUrl;
return {
client: new SlackApiClient(env, { workspaceUrl: urlForBrowser }),
Expand Down Expand Up @@ -182,6 +193,36 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{
);
}

function sameAuth(left: SlackAuth, right: SlackAuth | undefined): boolean {
if (!right) {
return false;
}
if (left.auth_type === "standard") {
return right.auth_type === "standard" && left.token === right.token;
}
return right.auth_type === "browser" && left.xoxc_token === right.xoxc_token;
}

function environmentAuthMatchesSelector(input: {
env: SlackAuth;
envWorkspaceUrl?: string;
selector?: string;
resolvedWorkspaceUrl?: string;
}): boolean {
if (!input.selector || !input.envWorkspaceUrl) {
return true;
}
const normalizedEnvUrl = tryNormalizeUrl(input.envWorkspaceUrl);
if (!normalizedEnvUrl) {
return false;
}
const selector = input.resolvedWorkspaceUrl ?? input.selector;
return Boolean(
resolveWorkspaceSelector([{ workspace_url: normalizedEnvUrl, auth: input.env }], selector)
.match,
);
}

async function matchAndUpsertBrowserTeam(input: {
teams: { url: string; name?: string; token: string }[];
cookieD: string;
Expand Down
11 changes: 9 additions & 2 deletions src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { loadCredentials, upsertWorkspaces } from "../auth/store.ts";
import { normalizeChannelInput } from "../slack/channels.ts";
import type { SlackApiClient } from "../slack/client.ts";
import { type SlackAuth } from "../slack/client.ts";
import { getClientForWorkspace, normalizeUrl } from "./context-client-resolver.ts";
import {
getClientForWorkspace,
normalizeUrl,
type ClientResolutionOptions,
} from "./context-client-resolver.ts";

export type CliContext = {
effectiveWorkspaceUrl: (flag?: string) => string | undefined;
Expand All @@ -19,7 +23,10 @@ export type CliContext = {
workspaceUrl: string | undefined;
work: () => Promise<T>;
}) => Promise<T>;
getClientForWorkspace: (workspaceUrl?: string) => Promise<{
getClientForWorkspace: (
workspaceUrl?: string,
options?: ClientResolutionOptions,
) => Promise<{
client: SlackApiClient;
auth: SlackAuth;
workspace_url?: string;
Expand Down
50 changes: 38 additions & 12 deletions src/cli/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import { normalizeSlackReactionName } from "../slack/emoji.ts";
import { warnOnTruncatedSlackUrl } from "./message-url-warning.ts";
import { textToRichTextBlocks } from "../slack/rich-text.ts";
import { formatOutboundSlackText } from "../slack/format-outbound.ts";
import type { SlackApiClient } from "../slack/client.ts";
import type { SlackApiClient, SlackAuth } from "../slack/client.ts";
import { uploadLocalFileToSlack } from "../slack/upload.ts";
import { buildSlackMessageUrl } from "../slack/url.ts";
import { normalizeAttachPaths } from "./options.ts";
import { resolveSchedulePostAt } from "../slack/scheduled-messages.ts";
import {
resolveSchedulePostAt,
scheduleMessage as scheduleMessageApi,
} from "../slack/scheduled-messages.ts";
import { buildUnfurlApiParams } from "./unfurl-options.ts";
import { resolveSlackNativeDraftEndpoint } from "./slack-native-draft-endpoint.ts";

function loadBlocksFromPath(path: string): unknown[] {
const raw = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8");
Expand Down Expand Up @@ -148,20 +152,25 @@ export async function sendMessage(input: {
return await input.ctx.withAutoRefresh({
workspaceUrl: ref.workspace_url,
work: async () => {
const { client, workspace_url } = await input.ctx.getClientForWorkspace(ref.workspace_url);
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(
ref.workspace_url,
);
const msg = await fetchMessage(client, { ref });
const threadTs = msg.thread_ts ?? msg.ts;
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? ref.workspace_url,
channelId: ref.channel_id,
text: formattedText,
draftText: input.text,
blocks,
threadTs,
replyBroadcast: input.options.replyBroadcast,
attachPaths,
postAt,
unfurl: input.options.unfurl,
auth,
ctx: input.ctx,
});
},
});
Expand All @@ -175,17 +184,20 @@ export async function sendMessage(input: {
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const dmChannelId = await openDmChannel(client, target.userId);
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? workspaceUrl,
channelId: dmChannelId,
text: formattedText,
draftText: input.text,
blocks,
attachPaths,
postAt,
unfurl: input.options.unfurl,
auth,
ctx: input.ctx,
});
},
});
Expand All @@ -202,19 +214,22 @@ export async function sendMessage(input: {
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const channelId = await resolveChannelId(client, String(target.channel));
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? workspaceUrl,
channelId,
text: formattedText,
draftText: input.text,
blocks,
threadTs: input.options.threadTs ? String(input.options.threadTs) : undefined,
replyBroadcast: input.options.replyBroadcast,
attachPaths,
postAt,
unfurl: input.options.unfurl,
auth,
ctx: input.ctx,
});
},
});
Expand All @@ -225,22 +240,33 @@ async function sendMessageToChannel(input: {
workspaceUrl?: string;
channelId: string;
text: string;
draftText: string;
blocks?: unknown[] | null;
threadTs?: string;
replyBroadcast?: boolean;
attachPaths: string[];
postAt?: number;
unfurl?: boolean;
auth: SlackAuth;
ctx: CliContext;
}): Promise<Record<string, unknown>> {
if (input.postAt !== undefined) {
const resp = await input.client.api("chat.scheduleMessage", {
channel: input.channelId,
const endpoint = await resolveSlackNativeDraftEndpoint({
ctx: input.ctx,
client: input.client,
auth: input.auth,
workspaceUrl: input.workspaceUrl,
});
const resp = await scheduleMessageApi(endpoint.client, {
authType: endpoint.auth.auth_type,
channelId: input.channelId,
text: input.text,
post_at: input.postAt,
thread_ts: input.threadTs,
...(input.blocks ? { blocks: input.blocks } : {}),
...(input.replyBroadcast && input.threadTs ? { reply_broadcast: true } : {}),
...buildUnfurlApiParams(input.unfurl),
draftText: input.draftText,
postAt: input.postAt,
threadTs: input.threadTs,
replyBroadcast: input.replyBroadcast,
blocks: input.blocks,
unfurl: input.unfurl,
});
const channelId = typeof resp.channel === "string" ? resp.channel : input.channelId;
const scheduledMessageId =
Expand Down
Loading
Loading