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', 'channels', 'DMs', 'mentions'."
personal 2 files 3 recent evals

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 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.

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
present
fixes itself learns from gaps
When a run hits a gap, the skill gets edited on the spot [FIXED] or queued for a bigger rewrite [LOGGED], so it keeps getting better.
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)

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.openui file renders channel/DM/mention counts and a recent-message table via the toolProvider get_inbox_slack query.
  • 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 server's per-skill serve route emits the dashboard using state/skills/slack/resources/ui.openui.
  2. For programmatic reads, call state/lib/slack.ts directly: listChannels(), listDMs(), listMentions().
  3. The sweep skill aggregates Slack alongside other channels via state/lib/sweep.ts sweepAll(). Use sweep for 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 using Query("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

  1. Read-side only. This skill does NOT post or send. For posting, route to a write-side skill (Typefully, webhook poster, etc.).
  2. Channel-list mode returns empty ts/user. When /inbox/slack is called with no args, only channel metadata is returned. Don't render those fields if they're empty.
  3. Credentials gate. SLACK_TOKEN must be set in .env.cache. If missing, listChannels() throws - surface the credential gap, don't silently return empty.
  4. Use sweep for multi-channel. The sweep skill aggregates Slack + Gmail + LinkedIn + Skool together. Route to sweep for the morning brief; use this skill for Slack-only reads.
  5. Eval is auto-shape. No behavioral check at runtime. Frontmatter + AGENTS.md presence is the gate.
  6. Dashboard uses Query("get_inbox_slack"). The ui.openui file at state/skills/slack/resources/ui.openui binds to the get_inbox_slack toolProvider endpoint. Do not inline raw data into the dashboard template.

Commands

operationcommand
list channelsimport { listChannels } from "./state/lib/slack.ts"
list DMsimport { listDMs } from "./state/lib/slack.ts"
list mentionsimport { listMentions } from "./state/lib/slack.ts"
CLI smokenpx tsx state/lib/slack.ts
credentials.env.cacheSLACK_TOKEN
dashboardstate/skills/slack/resources/ui.openui
full sweepstate/lib/sweep.tssweepAll() (multi-channel, includes Slack)

Self-Test

After reading this loader:

  • [ ] Route Slack-only reads to state/lib/slack.ts functions.
  • [ ] Route multi-channel morning brief to sweep skill.
  • [ ] Check SLACK_TOKEN in .env.cache before any read.
  • [ ] Never use this skill to post or send.
  • [ ] Dashboard template binds to get_inbox_slack query, 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 through Query("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

rubric auto-shape no rubric declared
recent mean 1.00 · 3 runs actor/auditor: unverifiable
deps none declared
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 - -