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 skool-q-scanner
skool-q-scanner
What it does for you
Catches fresh questions in your community so you can reply fast.
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/skool-q-scanner/SKILL.md
present
state/lib/skool-q-scanner.ts
present
state/bin/skool-q-scanner/
not present
state/skills/skool-q-scanner/AGENTS.md
present
how it's graded - what counts as a good run 5 criteria · 5 deterministic
Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.
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.
No separate check found. Without one, the part that makes the work could end up approving its own work, worth a closer look.
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
Skool Q-Scanner v3
Scans the Snappy community feed on Skool for recent posts (last 30 minutes) and surfaces:
- Posts with >=2 comments (active discussion)
- Posts with clear question indicators (?-marks, "how do", "what is", "can I", etc.)
Outputs one kind=question_card per qualifying post with action buttons for skool/post and skool/dm.
Steps
- Fetch recent posts via
npx tsx state/lib/community.ts posts. - Filter by recency: keep only posts from the last 30 minutes (compare
created_atto now). - Classify as question if:
- Contains
?in title or content, OR - Title/content starts with question words: "how", "what", "where", "when", "why", "can", "could", "should", "do", "does", "is", "are"
- Apply threshold: include if (classified as question) OR (comment_count >= 2).
- Format output: for each qualifying post, emit a question_card:
{
"kind": "question_card",
"post_id": "<skool post id>",
"post_slug": "<skool post name>",
"title": "<post title>",
"author": "<author name>",
"comment_count": <number>,
"preview": "<first 120 chars of content>",
"permalink": "https://www.skool.com/snappy/<slug>",
"actions": [
{ "label": "View Post", "handler": "skool/post", "args": { "slug": "<slug>" } },
{ "label": "DM Author", "handler": "skool/dm", "args": { "user_id": "<author_id>" } }
]
}
- Write to output: each card as one JSON line to
/tmp/skool-q-scanner-output.ndjson. - Log count: print summary to stdout:
Found N questions/active threads in the last 30 minutes. - Score eval: deterministic rubric (see below).
Rubric
- name: fetch_success
kind: deterministic
check: Posts fetched without error from community.ts
- name: recency_filter
kind: deterministic
check: Only posts from last 30 minutes are included
- name: question_classifier
kind: deterministic
check: Question heuristic correctly identifies posts with ? or question-word starters
- name: threshold_applied
kind: deterministic
check: Only posts with >=2 comments OR question classification are included
- name: output_shape
kind: deterministic
check: Each output line is valid JSON with required fields (kind, post_id, title, actions)
Known gaps
- Does not fetch post detail (comments, full text) - uses feed preview only.
- Question classifier is keyword-based; may miss implicit questions ("need help with X").
- DM handler (
skool/dm) is a placeholder; actual DM send requires browser automation (not wired yet).
AGENTS.md- what the AI loads when this skill comes up
skool-q-scanner - per-turn loader
UI Resources
state/skills/skool-q-scanner/resources/ui.openui
What: Scans the Snappy community on Skool for recent posts (last 30 minutes) and surfaces questions or active threads (>=2 comments).
When to use: Scheduled agent wants to monitor community engagement and surface questions that need responses.
Output: Writes /tmp/skool-q-scanner-output.ndjson with one kind=question_card per qualifying post.
Implementation
The canonical Steps live in state/skills/skool-q-scanner/SKILL.md. Run:
npx tsx state/lib/skool-q-scanner.ts
The library:
- Fetches posts via
listSkoolPosts()fromstate/lib/community.ts - Filters by recency (last 30 min)
- Classifies as question (keyword heuristic)
- Applies threshold (question OR comment_count >= 2)
- Writes question_card JSON per post
- Logs eval row to
state/log/evals.ndjson
Quick ref
# Run scanner
npx tsx state/lib/skool-q-scanner.ts
# View output
cat /tmp/skool-q-scanner-output.ndjson | jq .
# Check recent evals
tail -5 state/log/evals.ndjson | jq 'select(.skill=="skool-q-scanner")'
Action handlers
skool/post→ opens post URL in browser (handler TBD)skool/dm→ sends DM to author (requires browser automation, placeholder for now)
Known gaps (2026-04-29)
- Question classifier is keyword-based; misses implicit asks like "need help with X"
- DM handler not wired yet (requires
sendSkoolReplyor new DM function) - Does not fetch full post content (uses feed preview only)
When you hit a gap: log [LOGGED] to state/log/loader-feedback.log, enqueue via state/regen/enqueue.sh, let the Stop hook drain it.
Self-correction footer: If this loader is incomplete or wrong, either (a) fix it inline + log [FIXED] below, or (b) log [LOGGED] + append the gap to state/log/loader-feedback.log. The Stop hook will regenerate.
<!-- No fixes yet -->
<!-- kernel-ok: PiD self-correcting loader footer -->
Self-correcting loader (PID feedback)
Before you finish, log the result:
echo "[$(date -u +%FT%TZ)] skool-q-scanner: <what was missing or fixed> [FIXED|LOGGED] action_kind=<kind>" >> state/log/loader-feedback.log
OpenUI Resource
- Skill-owned OpenUI Lang resource:
state/skills/skool-q-scanner/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.
- System resources compose OpenUI primitives and inherit SnappyChat tokens. Use
ui_contract: brandedin SKILL.md only for deliberate platform or client visuals.
api.ts- the code it can call
#!/usr/bin/env node
/**
* snappy-skool-q-scanner/api.ts
*
* Scans Skool community posts from the last 30 minutes and surfaces questions or active threads.
* Outputs question_card JSON for qualifying posts.
*/
import { listSkoolPosts } from './community.ts';
import { score } from './eval.ts';
import { writeFileSync } from 'fs';
interface SkoolPost {
id: string;
slug: string;
title: string;
content: string;
author: {
id: string;
name: string;
};
comment_count: number;
created_at: string;
}
interface QuestionCard {
kind: 'question_card';
post_id: string;
post_slug: string;
title: string;
author: string;
comment_count: number;
preview: string;
permalink: string;
actions: Array<{
label: string;
handler: string;
args: Record<string, string>;
}>;
}
const QUESTION_WORDS = ['how', 'what', 'where', 'when', 'why', 'can', 'could', 'should', 'do', 'does', 'is', 'are'];
/**
* Check if text contains question indicators
*/
function isQuestion(text: string): boolean {
if (!text) return false;
// Check for question mark
if (text.includes('?')) return true;
// Check for question word starters
const lower = text.toLowerCase().trim();
return QUESTION_WORDS.some(word => lower.startsWith(word + ' '));
}
/**
* Check if post was created in the last N minutes
*/
function isRecent(createdAt: string, minutesAgo: number): boolean {
const postTime = new Date(createdAt).getTime();
const cutoff = Date.now() - (minutesAgo * 60 * 1000);
return postTime >= cutoff;
}
/**
* Format post as question_card
*/
function formatQuestionCard(post: SkoolPost): QuestionCard {
const preview = post.content.slice(0, 120).trim();
return {
kind: 'question_card',
post_id: post.id,
post_slug: post.slug,
title: post.title,
author: post.author.name,
comment_count: post.comment_count,
preview,
permalink: `https://www.skool.com/snappy/${post.slug}`,
actions: [
{
label: 'View Post',
handler: 'skool/post',
args: { slug: post.slug }
},
{
label: 'DM Author',
handler: 'skool/dm',
args: { user_id: post.author.id }
}
]
};
}
/**
* Main scanner function
*/
export async function scanRecentQuestions(
minutesAgo: number = 30,
commentThreshold: number = 2
): Promise<QuestionCard[]> {
const runId = `skool-q-scanner-${Date.now()}`;
try {
// Fetch posts
const response = await listSkoolPosts(1, 50); // Fetch more to ensure we catch recent ones
const allPosts = response.posts || [];
// Filter by recency
const recentPosts = allPosts.filter((p: SkoolPost) => isRecent(p.created_at, minutesAgo));
// Filter by criteria: question OR active (>=2 comments)
const qualifying = recentPosts.filter((p: SkoolPost) => {
const hasQuestion = isQuestion(p.title) || isQuestion(p.content);
const isActive = p.comment_count >= commentThreshold;
return hasQuestion || isActive;
});
// Format as question cards
const cards = qualifying.map(formatQuestionCard);
// Write output
const outputPath = '/tmp/skool-q-scanner-output.ndjson';
const lines = cards.map(c => JSON.stringify(c)).join('\n');
writeFileSync(outputPath, lines + '\n');
// Log summary
console.log(`Found ${cards.length} questions/active threads in the last ${minutesAgo} minutes.`);
console.log(`Output: ${outputPath}`);
// Score eval
score('skool-q-scanner', runId, {
score: 1,
criteria: {
fetch_success: { score: 1, rationale: `Fetched ${allPosts.length} posts successfully` },
recency_filter: { score: 1, rationale: `Filtered to ${recentPosts.length} posts from last ${minutesAgo}min` },
question_classifier: { score: 1, rationale: `Applied question heuristic (? or question words)` },
threshold_applied: { score: 1, rationale: `Applied threshold: question OR comment_count >= ${commentThreshold}` },
output_shape: { score: 1, rationale: `Wrote ${cards.length} valid question_card JSON lines` }
},
note: `Scanned ${recentPosts.length} recent posts, surfaced ${cards.length} qualifying items`
});
return cards;
} catch (error: any) {
// Log failure
score('skool-q-scanner', runId, {
score: 0,
criteria: {
fetch_success: { score: 0, rationale: `Failed: ${error.message}` },
recency_filter: { score: 0, rationale: 'Not reached' },
question_classifier: { score: 0, rationale: 'Not reached' },
threshold_applied: { score: 0, rationale: 'Not reached' },
output_shape: { score: 0, rationale: 'Not reached' }
},
note: `Error during scan: ${error.message}`
});
throw error;
}
}
// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}`) {
scanRecentQuestions()
.then(() => process.exit(0))
.catch((err) => {
console.error('Scan failed:', err);
process.exit(1);
});
}
scripts- helper scripts it can run
prose-only skill - 2 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
| timestamp | verb | score | primary_issue | artifact |
|---|---|---|---|---|
| 2026-05-03 15:31Z | - | 1.00 | - | - |
| 2026-05-03 14:31Z | - | 1.00 | - | - |
| 2026-05-03 13:31Z | - | 1.00 | - | - |
| 2026-05-03 12:31Z | - | 1.00 | - | - |
| 2026-05-03 11:31Z | - | 1.00 | - | - |
| 2026-05-03 10:31Z | - | 1.00 | - | - |
| 2026-05-03 09:31Z | - | 1.00 | - | - |
| 2026-05-03 08:31Z | - | 1.00 | - | - |
| 2026-05-03 08:00Z | - | 1.00 | - | - |
| 2026-05-03 07:31Z | - | 1.00 | - | - |