OR Key
drop another .md file to compare - side-by-side diff against slack

slack

Brings your Slack channels, DMs, and mentions into one view.
description: "Triggers on prompt mention of 'slack'."
personal 2 files

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.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

eval modeauto-shape
categoryChannels

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.

The skill
state/skills/slack/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/slack.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/slack/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/slack/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

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.

makes the work The worker
not present

No work step here. This is probably a skill that reads or coordinates, not one that produces something.

checks the work The reviewer
inferred
shape gate an automatic check
The check is an automatic pass or fail on the shape of the result, run separately from the work itself.
frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson auto-shape runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
No must-not-break rules called out for this skill. Anything important lives in the writeup below.

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.

  1. 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/slack no-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 by get_inbox_slack.
  • Reference. Any agent asking "what's in Slack?" gets routed here first; state/lib/slack.ts is 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

  1. Mention "slack" or "show me slack" in a chat. The runtime grounds Slack state and composes the dashboard through the live OpenUI path.
  2. For programmatic reads, call state/lib/slack.ts directly: listChannels(), listDMs(), listMentions().
  3. 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 artifact lang_body only 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 Card with a Callout("Select a Channel") and ListBlock of channel names
  • Add a FollowUpBlock with one SnappyFollowUpItem per 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-snappyC09DD2D0S07, #socialC09DD2D0T7H, #techC0A1B3Q2BH9, #bugs-and-issuesC09KKEYAH1V.

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

rubric auto-shape no rubric declared
recent no runs actor/auditor: unverifiable
deps none declared

no recent runs logged - the eval contract is declared but nothing has been graded yet