No work step here. This is probably a skill that reads or coordinates, not one that produces something.
.md file to compare - side-by-side diff against slack
slack
description: "Triggers on prompt mention of 'slack'."
What it does for you
Brings your Slack channels, DMs, and mentions into one view.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/slack/SKILL.md
present
state/lib/slack.ts
present
state/bin/slack/
not present
state/skills/slack/AGENTS.md
present
how it runs - the shared frame every skill uses 2/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
This skill doesn't fix its own gaps yet.
state/log/evals.ndjson what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
SKILL.md- the skill, written out in plain English
slack
Read-side wrapper around the Slack API. Backed by state/lib/slack.ts. Surfaces:
- Channel list (
/inbox/slackno-args mode) - Direct messages (filter by
is_dm: true) - Mentions (filter by
is_mention: true)
Slack UI is composed through the live OpenUI path, grounded by get_inbox_slack / state/lib/slack.ts, to render channel/DM/mention KPIs and recent activity.
What it's for
- Dashboard rendering. Compose channel/DM/mention counts and a recent-message table via
compose_inline, grounded byget_inbox_slack. - Reference. Any agent asking "what's in Slack?" gets routed here first;
state/lib/slack.tsis the data primitive.
When NOT to use it
- Posting to Slack. Route to a write-side skill (webhook poster, etc.). This skill is read-only.
- Real-time event streaming. The lib uses polling, not WebSocket. For latency-sensitive integrations, wire a webhook receiver separately.
Steps
- Mention "slack" or "show me slack" in a chat. The runtime grounds Slack state and composes the dashboard through the live OpenUI path.
- For programmatic reads, call
state/lib/slack.tsdirectly:listChannels(),listDMs(),listMentions(). - Use this skill when you need Slack data alone; blended dashboards should bind Slack through a real
Query()handle alongside the other selected handles.
Library API
import { listChannels, listDMs, listMentions } from './state/lib/slack.ts';
const channels = await listChannels(); // [{id, name, unread_count, ...}]
const dms = await listDMs(); // [{id, user, unread_count, ...}]
const mentions = await listMentions(); // [{ts, text, channel, user, ...}]
Credentials read from .env.cache (SLACK_TOKEN).
Eval
Kind: auto-shape. Frontmatter presence + AGENTS.md presence passes the gate. No behavioral test yet - behavioral eval pending a live SLACK_TOKEN in .env.cache.
Files
state/lib/slack.ts- the API (importable + CLI-runnable).- Live OpenUI surface - composed from
get_inbox_slack/state/lib/slack.ts; persisted as artifactlang_bodyonly when it becomes a reusable Live App.
AGENTS.md- what the AI loads when this skill comes up
slack - loader
Write intent (draft / compose / send / post / ping / message)
Emit a SlackMessagePreview Lang component. That's the whole rule.
root = SlackMessagePreview("<channel>", "<senderName>", "<message>", null, null, null, null, null, null, null, null, null, null)
13 positional args (channel, senderName, body, 10 trailing nulls). The preview IS the deliverable; user copies/sends manually.
Read intent (show / check / read / inbox)
Call GET /inbox/slack (no args) to get the channel list, then render a picker.
Channel picker response shape
{
"ok": true,
"requires_channel_selection": true,
"channels": [{ "channel_id": "C...", "channel_name": "general", "member_count": 12, "is_member": true }],
"messages": []
}
When requires_channel_selection: true:
- Render a
Cardwith aCallout("Select a Channel")andListBlockof channel names - Add a
FollowUpBlockwith oneSnappyFollowUpItemper channel:prompt="show slack messages in #<channel_name>",displayText="#<channel_name>" - Do NOT render an error fallback - channel selection is expected behavior, not an error
Messages response shape (after channel is selected)
GET /inbox/slack?channel=<channel_id> returns { ok: true, channel: "...", messages: [...] }.
Each message: { ts, user, text, reactions?, reply_count? }.
Render as a Card with a Table or ListBlock of recent messages. Include sender, text snippet, and timestamp.
Do NOT call the read tool. Do NOT look up state/lib/slack.ts.
Send Pattern (MANDATORY for write intents)
slack-pp-cli is READ-ONLY. To SEND a message, use the Web API directly:
SLACK_BOT_TOKEN=$(grep '^SLACK_BOT_TOKEN' ~/projects/snappy-os/.env.cache | cut -d= -f2- | tr -d "\"'")
# Step 1 - resolve channel ID (this workspace has no #general; list channels first)
curl -s "https://slack.com/api/conversations.list?types=public_channel&limit=20" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | python3 -c \
"import sys,json; [print(c['name'], c['id']) for c in json.load(sys.stdin).get('channels',[])]"
# Step 2 - send (use channel ID from step 1, e.g. C09DD2D0S07 for #all-snappy)
curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"channel\": \"CHANNEL_ID\", \"text\": \"$MESSAGE\"}"
Known channels: #all-snappy → C09DD2D0S07, #social → C09DD2D0T7H, #tech → C0A1B3Q2BH9, #bugs-and-issues → C09KKEYAH1V.
IMPORTANT: The bot must be a member of the target channel to send. Bot scopes include chat:write but NOT channels:join. If you get not_in_channel, the bot needs to be manually invited: /invite @snappy-bot in the Slack channel. Alternatively, send as a DM by using the user's Slack member ID as the channel value.
After sending, confirm success by checking the API response: "ok": true means sent.
Send intents: "send to slack", "post to slack", "message #channel", "slack this to", "tell #channel", "post in slack" <!-- footer-injection-point -->
api.ts- the code it can call
#!/usr/bin/env npx tsx
/**
* snappy-slack/api.ts -- Slack API operations for all snappy-* skills.
*
* Uses Slack Bot Token from snappy-settings/.env.cache.
* Direct Slack Web API calls -- no Xano middleware.
*
* Usage:
* npx tsx api.ts channels # list channels
* npx tsx api.ts messages C09DD2D0S07 # read channel history
* npx tsx api.ts send C09DD2D0S07 "Hello from the agent"
*
* Or import as module:
* import { listChannels, readMessages, sendSlackMessage, editSlackMessage, deleteSlackMessage } from "./slack.ts";
*/
import { env } from "./env.ts";
import { realpathSync } from "fs";
const SLACK_API = "https://slack.com/api";
function token(): string {
return env("SLACK_USER_TOKEN", false) || env("SLACK_BOT_TOKEN");
}
async function slack(method: string, body?: Record<string, unknown>) {
const res = await fetch(`${SLACK_API}/${method}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json; charset=utf-8",
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!data.ok) {
throw new Error(`Slack ${method} failed: ${data.error}`);
}
return data;
}
// --- Helpers ---
async function resolveChannel(name: string): Promise<string> {
const clean = name.replace(/^#/, "");
const data = await listChannels(200);
const ch = data.channels.find((c: { name: string }) => c.name === clean);
if (!ch) throw new Error(`Channel "${clean}" not found. Use 'channels' command to list.`);
return ch.id;
}
// --- Public API ---
export async function listChannels(limit = 100) {
return slack("conversations.list", {
types: "public_channel,private_channel",
limit,
exclude_archived: true,
});
}
export async function readMessages(channelId: string, limit = 20) {
return slack("conversations.history", { channel: channelId, limit });
}
export async function sendSlackMessage(channelId: string, text: string, threadTs?: string) {
return slack("chat.postMessage", {
channel: channelId,
text,
...(threadTs ? { thread_ts: threadTs } : {}),
});
}
export async function sendDm(userId: string, text: string) {
const { channel } = await slack("conversations.open", { users: userId });
return sendSlackMessage(channel.id, text);
}
export async function replyInThread(channelId: string, threadTs: string, text: string) {
return sendSlackMessage(channelId, text, threadTs);
}
// ---------------------------------------------------------------------------
// Sensor: recentMessages
// ---------------------------------------------------------------------------
export interface SensorReading<T> {
name: string;
value: T;
fetched_at: string;
cache_hit: boolean;
ttl_seconds: number;
source: string[];
freshness: "live" | "cached" | "stale";
error?: string;
}
interface SlackMessageHit {
channel_id: string;
channel_name?: string;
ts: string;
user: string;
text: string;
is_dm: boolean;
is_mention: boolean;
permalink?: string;
}
const _slackSensorCache = new Map<
string,
{ value: SlackMessageHit[]; fetched_at: number; error?: string }
>();
const RECENT_MSG_TTL = 300; // seconds
/**
* Resolve a Slack handle (user ID `U...`, display name, or `@name`) → user ID.
* Falls back to the original input if resolution fails.
*/
async function resolveUserId(handle: string): Promise<string> {
const clean = handle.replace(/^@/, "");
if (/^U[A-Z0-9]+$/.test(clean)) return clean;
try {
// users.list is paginated; first page covers most workspaces
const data = await slack("users.list", { limit: 200 });
const members: any[] = data?.members ?? [];
const lc = clean.toLowerCase();
const hit = members.find(
(m) =>
m?.name?.toLowerCase() === lc ||
m?.profile?.display_name?.toLowerCase() === lc ||
m?.profile?.real_name?.toLowerCase() === lc,
);
return hit?.id || clean;
} catch {
return clean;
}
}
/**
* Get recent DMs + mentions across all contacts.
* Fetches all DM channels, reads last message from each, sorts by recency.
*
* Returns a flat list of recent messages across all DMs.
*/
export async function listRecent(
opts: { limit?: number } = {},
): Promise<SlackMessageHit[]> {
const limit = opts.limit ?? 10;
try {
// Get all DM conversations
const convs = await slack("conversations.list", {
types: "im",
limit: 100,
exclude_archived: true,
});
const dmChannels: any[] = (convs as any)?.channels ?? [];
const hits: SlackMessageHit[] = [];
// For each DM, fetch last message
for (const dmCh of dmChannels.slice(0, 50)) {
try {
const hist = await slack("conversations.history", {
channel: dmCh.id,
limit: 1,
});
const lastMsg = ((hist as any)?.messages ?? [])[0];
if (lastMsg) {
hits.push({
channel_id: dmCh.id,
channel_name: dmCh.user ? `DM: ${dmCh.user}` : "DM",
ts: lastMsg.ts || "",
user: lastMsg.user || "bot",
text: lastMsg.text || "",
is_dm: true,
is_mention: false,
});
}
} catch {
// ignore channel read errors
}
}
// Sort by ts desc, cap at limit
hits.sort((a, b) => Number(b.ts) - Number(a.ts));
return hits.slice(0, limit);
} catch {
return [];
}
}
/**
* Sensor — last 10 DMs and channel mentions involving the given handle.
* Handle may be a Slack user ID (`U...`), display name, or `@name`.
*
* Composition:
* 1. Resolve handle → userId via users.list
* 2. Open DM channel via conversations.open and read history (DMs)
* 3. Use search.messages with `from:@user OR <@user>` for mentions
* (requires user-token / `search:read` scope; degrades gracefully)
*
* TTL 300s. Returns SensorReading<SlackMessageHit[]>.
*/
export async function recentMessages(
handle: string,
opts: { limit?: number } = {},
): Promise<SensorReading<SlackMessageHit[]>> {
const limit = opts.limit ?? 10;
const name = "slack.recentMessages";
const sources = [
"slack:conversations.open",
"slack:conversations.history",
"slack:search.messages",
];
const cacheKey = `${name}:${handle}:${limit}`;
const now = Date.now();
const cached = _slackSensorCache.get(cacheKey);
if (cached && (now - cached.fetched_at) / 1000 < RECENT_MSG_TTL && !cached.error) {
const age = (now - cached.fetched_at) / 1000;
return {
name,
value: cached.value,
fetched_at: new Date(cached.fetched_at).toISOString(),
cache_hit: true,
ttl_seconds: RECENT_MSG_TTL,
source: sources,
freshness: age < 2 ? "live" : "cached",
};
}
try {
const userId = await resolveUserId(handle);
const hits: SlackMessageHit[] = [];
// 1) DMs — open + read history
try {
const opened = await slack("conversations.open", { users: userId });
const dmChannel = opened?.channel?.id;
if (dmChannel) {
const hist = await slack("conversations.history", { channel: dmChannel, limit });
for (const msg of hist?.messages ?? []) {
hits.push({
channel_id: dmChannel,
ts: msg.ts,
user: msg.user || "bot",
text: msg.text || "",
is_dm: true,
is_mention: false,
});
}
}
} catch {
// DM history may be unavailable for bot tokens — degrade silently
}
// 2) Mentions — search.messages (requires user token + search:read)
try {
const query = `<@${userId}>`;
const search = await slack("search.messages", { query, count: limit, sort: "timestamp" });
const matches: any[] = search?.messages?.matches ?? [];
for (const m of matches) {
hits.push({
channel_id: m?.channel?.id || "",
channel_name: m?.channel?.name,
ts: m?.ts || "",
user: m?.user || "bot",
text: m?.text || "",
is_dm: false,
is_mention: true,
permalink: m?.permalink,
});
}
} catch {
// search.messages requires user token; bots get not_allowed_token_type. OK.
}
// Sort by ts desc, dedupe by (channel_id, ts), cap at limit
hits.sort((a, b) => Number(b.ts) - Number(a.ts));
const seen = new Set<string>();
const deduped: SlackMessageHit[] = [];
for (const h of hits) {
const key = `${h.channel_id}:${h.ts}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(h);
if (deduped.length >= limit) break;
}
_slackSensorCache.set(cacheKey, { value: deduped, fetched_at: now });
return {
name,
value: deduped,
fetched_at: new Date(now).toISOString(),
cache_hit: false,
ttl_seconds: RECENT_MSG_TTL,
source: sources,
freshness: "live",
};
} catch (e: any) {
const msg = e?.message || String(e);
const fallback = (cached?.value ?? []) as SlackMessageHit[];
_slackSensorCache.set(cacheKey, { value: fallback, fetched_at: now, error: msg });
return {
name,
value: fallback,
fetched_at: new Date(now).toISOString(),
cache_hit: false,
ttl_seconds: RECENT_MSG_TTL,
source: sources,
freshness: "stale",
error: msg,
};
}
}
// --- Mutations (reactions, edit, delete, pins) ---
export async function addReaction(channelId: string, timestamp: string, emoji: string) {
return slack("reactions.add", { channel: channelId, timestamp, name: emoji.replace(/:/g, "") });
}
export async function removeReaction(channelId: string, timestamp: string, emoji: string) {
return slack("reactions.remove", {
channel: channelId,
timestamp,
name: emoji.replace(/:/g, ""),
});
}
export async function editSlackMessage(channelId: string, ts: string, text: string) {
return slack("chat.update", { channel: channelId, ts, text });
}
export async function deleteSlackMessage(channelId: string, ts: string) {
return slack("chat.delete", { channel: channelId, ts });
}
export async function pinMessage(channelId: string, timestamp: string) {
return slack("pins.add", { channel: channelId, timestamp });
}
export async function unpinMessage(channelId: string, timestamp: string) {
return slack("pins.remove", { channel: channelId, timestamp });
}
// --- Rich messaging (Block Kit) ---
export async function sendBlocks(
channelId: string,
blocks: unknown[],
text?: string,
threadTs?: string,
) {
return slack("chat.postMessage", {
channel: channelId,
blocks,
text: text || "",
...(threadTs ? { thread_ts: threadTs } : {}),
});
}
// --- File operations ---
export async function uploadFile(
channelId: string,
content: string | Buffer,
filename: string,
title?: string,
) {
// Step 1: get upload URL
const getUrl = await slack("files.getUploadURLExternal", {
filename,
length: typeof content === "string" ? Buffer.byteLength(content) : content.length,
});
// Step 2: upload to the URL
const uploadRes = await fetch(getUrl.upload_url, {
method: "POST",
body:
typeof content === "string"
? (Buffer.from(content) as unknown as BodyInit)
: (content as BodyInit),
});
if (!uploadRes.ok) throw new Error(`File upload failed: ${uploadRes.status}`);
// Step 3: complete upload
return slack("files.completeUploadExternal", {
files: [{ id: getUrl.file_id, title: title || filename }],
channel_id: channelId,
});
}
// --- Search ---
export async function searchSlackMessages(query: string, limit = 20) {
const url = `${SLACK_API}/search.messages?${new URLSearchParams({ query, count: String(limit), sort: "timestamp" })}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token()}` } });
const data = await res.json();
if (!data.ok) throw new Error(`Slack search.messages failed: ${data.error}`);
return data;
}
// --- Thread history ---
export async function getThreadReplies(channelId: string, threadTs: string, limit = 50) {
return slack("conversations.replies", { channel: channelId, ts: threadTs, limit });
}
// --- User info ---
export async function getUserInfo(userId: string) {
return slack("users.info", { user: userId });
}
export async function setChannelTopic(channelId: string, topic: string) {
return slack("conversations.setTopic", { channel: channelId, topic });
}
// --- CLI ---
if (
(() => {
try {
return import.meta.url === `file://${realpathSync(process.argv[1])}`;
} catch {
return false;
}
})()
) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "channels": {
const data = await listChannels();
for (const ch of data.channels) {
console.log(`${ch.id}\t${ch.name}\t${ch.num_members} members`);
}
break;
}
case "messages": {
const [channelId, limitStr] = args;
if (!channelId) {
console.error("Usage: api.ts messages <channel_id|channel_name> [limit]");
process.exit(1);
}
const resolved = channelId.startsWith("C") ? channelId : await resolveChannel(channelId);
const limit = limitStr ? parseInt(limitStr, 10) : 20;
const data = await readMessages(resolved, limit);
for (const msg of data.messages) {
const ts = new Date(Number(msg.ts) * 1000).toISOString().slice(0, 16);
console.log(`${ts}\t${msg.user || "bot"}\t${(msg.text || "").slice(0, 500)}`);
}
break;
}
case "send": {
const [channelId, ...textParts] = args;
if (!channelId || !textParts.length) {
console.error("Usage: api.ts send <channel_id|channel_name> <text>");
process.exit(1);
}
const resolvedSend = channelId.startsWith("C")
? channelId
: await resolveChannel(channelId);
await sendSlackMessage(resolvedSend, textParts.join(" "));
console.log("sent");
break;
}
case "react": {
const [chId, ts, emoji] = args;
if (!chId || !ts || !emoji) {
console.error("Usage: api.ts react <channel_id> <ts> <emoji>");
process.exit(1);
}
await addReaction(chId, ts, emoji);
console.log("reacted");
break;
}
case "edit": {
const [chId, ts, ...textParts] = args;
if (!chId || !ts || !textParts.length) {
console.error("Usage: api.ts edit <channel_id> <ts> <text>");
process.exit(1);
}
await editSlackMessage(chId, ts, textParts.join(" "));
console.log("edited");
break;
}
case "delete": {
const [chId, ts] = args;
if (!chId || !ts) {
console.error("Usage: api.ts delete <channel_id> <ts>");
process.exit(1);
}
await deleteSlackMessage(chId, ts);
console.log("deleted");
break;
}
case "search": {
const query = args.join(" ");
if (!query) {
console.error("Usage: api.ts search <query>");
process.exit(1);
}
const data = await searchSlackMessages(query);
for (const m of data?.messages?.matches ?? []) {
console.log(
`${m.channel?.name || m.channel?.id}\t${m.ts}\t${(m.text || "").slice(0, 200)}`,
);
}
break;
}
case "thread": {
const [chId, ts] = args;
if (!chId || !ts) {
console.error("Usage: api.ts thread <channel_id> <thread_ts>");
process.exit(1);
}
const data = await getThreadReplies(chId, ts);
for (const msg of data.messages) {
console.log(`${msg.user || "bot"}\t${(msg.text || "").slice(0, 300)}`);
}
break;
}
case "upload": {
const [chId, filepath] = args;
if (!chId || !filepath) {
console.error("Usage: api.ts upload <channel_id> <filepath>");
process.exit(1);
}
const { readFileSync: rf } = await import("fs");
const { basename } = await import("path");
await uploadFile(chId, rf(filepath), basename(filepath));
console.log("uploaded");
break;
}
default:
console.log(
"Usage: npx tsx api.ts [channels|messages|send|react|edit|delete|search|thread|upload] ...",
);
}
})();
}
scripts- helper scripts it can run
prose-only skill - 1 inline code block live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
no recent runs logged - the eval contract is declared but nothing has been graded yet