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', 'channels', 'DMs', 'mentions'."
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 3/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.
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)
The dashboard at state/skills/slack/resources/ui.openui uses Query("get_inbox_slack") to render channel/DM/mention KPIs and a recent activity table.
What it's for
- Inbox sweep. Pull unread Slack signals alongside Gmail/LinkedIn into the morning brief.
- Dashboard rendering. The
ui.openuifile renders channel/DM/mention counts and a recent-message table via the toolProviderget_inbox_slackquery. - 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 server's per-skill serve route emits the dashboard using
state/skills/slack/resources/ui.openui. - For programmatic reads, call
state/lib/slack.tsdirectly:listChannels(),listDMs(),listMentions(). - The
sweepskill aggregates Slack alongside other channels viastate/lib/sweep.tssweepAll(). Usesweepfor the multi-channel morning brief; use this skill when you need Slack data alone.
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).state/skills/slack/resources/ui.openui- dashboard template usingQuery("get_inbox_slack").
AGENTS.md- what the AI loads when this skill comes up
slack - loader
Per-turn rules. Full reference: state/skills/slack/SKILL.md. Lib: state/lib/slack.ts.
Critical Rules
- Read-side only. This skill does NOT post or send. For posting, route to a write-side skill (Typefully, webhook poster, etc.).
- Channel-list mode returns empty
ts/user. When/inbox/slackis called with no args, only channel metadata is returned. Don't render those fields if they're empty. - Credentials gate.
SLACK_TOKENmust be set in.env.cache. If missing,listChannels()throws - surface the credential gap, don't silently return empty. - Use
sweepfor multi-channel. Thesweepskill aggregates Slack + Gmail + LinkedIn + Skool together. Route tosweepfor the morning brief; use this skill for Slack-only reads. - Eval is auto-shape. No behavioral check at runtime. Frontmatter + AGENTS.md presence is the gate.
- Dashboard uses
Query("get_inbox_slack"). Theui.openuifile atstate/skills/slack/resources/ui.openuibinds to theget_inbox_slacktoolProvider endpoint. Do not inline raw data into the dashboard template.
Commands
| operation | command |
|---|---|
| list channels | import { listChannels } from "./state/lib/slack.ts" |
| list DMs | import { listDMs } from "./state/lib/slack.ts" |
| list mentions | import { listMentions } from "./state/lib/slack.ts" |
| CLI smoke | npx tsx state/lib/slack.ts |
| credentials | .env.cache → SLACK_TOKEN |
| dashboard | state/skills/slack/resources/ui.openui |
| full sweep | state/lib/sweep.ts → sweepAll() (multi-channel, includes Slack) |
Self-Test
After reading this loader:
- [ ] Route Slack-only reads to
state/lib/slack.tsfunctions. - [ ] Route multi-channel morning brief to
sweepskill. - [ ] Check
SLACK_TOKENin.env.cachebefore any read. - [ ] Never use this skill to post or send.
- [ ] Dashboard template binds to
get_inbox_slackquery, not inline data.
Self-correcting loader (PID feedback)
If this loader didn't cover your case - missing function, wrong credential name, undocumented endpoint - you MUST attempt an Edit before you log.
echo "[$(date -u +%FT%TZ)] slack: <what was missing or fixed> [FIXED|LOGGED] action_kind=skill-ran" >> state/log/loader-feedback.log
OpenUI Resource
- Skill-owned OpenUI Lang resource:
state/skills/slack/resources/ui.openui. Read it before rendering or editing this skill's generated component surface. - Treat this resource as a first-class artifact of the skill, not a generic chat response. Improve it when the skill's user-facing output needs to become richer.
- This resource owns Slack platform visual chrome and the SKILL.md declares
ui_contract: branded; keep data reactive throughQuery("get_inbox_slack")and do not move Slack brand colors into global SnappyChat tokens.
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;
}
}
/**
* 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 3 runs
| timestamp | verb | score | primary_issue | artifact |
|---|---|---|---|---|
| 2026-05-01 09:19Z | - | 1.00 | - | - |
| 2026-05-01 09:19Z | - | 1.00 | - | - |
| 2026-05-01 09:19Z | - | 1.00 | - | - |