snappy-skool skill
healthreadmereadgroupreadadminsreadlevelsreadcoursesreadcourse course_idreadlabelsreadlinksreadposts page?readpost-detail slugreadcomments slugread/groups/{slug}/posts/channels/{channelId}/messages$ npx snappy-skills install snappy-skool
$ npx snappy-skills install --all
$ npx snappy-skills update
Use api.ts as the only operational road. Do not recreate Skool calls with curl, guessed endpoints, or browser clicks.
bashnpx tsx ~/.claude/skills/snappy-skool/api.ts contract
Reads run now. post, comment, and dm stage through snappy-settings/stage.ts. Never add --now; only the approval executor may do that.
| Need | Command |
|---|---|
| Cookie health | npx tsx ~/.claude/skills/snappy-skool/api.ts health |
| Cookie owner | npx tsx ~/.claude/skills/snappy-skool/api.ts me --json |
| Group census | npx tsx ~/.claude/skills/snappy-skool/api.ts group |
| Admins, levels, labels, links | admins, levels, labels, links |
| Classroom | courses, course <id>, modules |
| Feed | posts [page], post-detail <slug>, comments <slug>, questions [page] |
| Members | members [page] |
| Other group reads | leaderboards, calendar, brief |
| Chats | chats [limit], then messages <channel-id> |
| Stage content | post <title> <body> [category], comment <slug> <body> |
| Stage DM | dm <channel-id> <body> |
Use --group <slug> for any Skool group. Default: snappy. Chats are account-scoped.
--json on a read is the FACE's object#posts --json prints skool-feed, post-detail --json prints skool-post, and
comments --json (spelled thread when you want the shape word) prints
skool-comments. Each carries kind, and its other keys ARE that face's props
-- the community's own name and member count, the author's name and avatar, the
category's WORDS, the excerpt, the like and comment counts. Draw one with
npx tsx ~/.claude/skills/snappy-faces/api.ts draw <kind> --data-file <file>.
WITHOUT --json a read answers the hand's own object, which is where id,
slug and permalink live -- use that when you need a handle to go on with,
then post-detail <slug>. Every other read (group, members, chats,
leaderboards, …) prints its own answer under --json as it always did,
because no Skool face draws those shapes yet.
Cookie jar: ~/.openclaw/workspace/skool-auth.json.
auth_token currently expires 2027-07-19 unless revoked.aws-waf-token for four days and AWSALB* for seven days.health says api2=true, site=false, refresh the headed browser state. Do not ask for a password first.Exact refresh commands and the sign-in fallback are in SKILL.md section 3.
The private API was measured from the live site's network log and current frontend bundle on 2026-09-08. Do not guess a path. If a captured path changes, stop and recapture before changing code.
The program uses:
api2.skool.com for group, admin, levels, course, category, link, and chat JSON.www.skool.com HTML __NEXT_DATA__ for account, feed, post, member, classroom, leaderboard, and calendar reads.securityNonce sent as x-sk-sec only after approval.post has effect post.comment has effect post.dm has effect send.StateChange is pro.statechange.ai, not Skool. It uses a separate browser state and is not served by --group. No snappy-statechange alias exists.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-skool Index]|root: ~/.claude/skills/snappy-skool|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
snappy-coursesnappy-image<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
health |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts health |
me |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts me |
group |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts group |
admins |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts admins |
levels |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts levels |
courses |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts courses |
course |
course_id |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts course <course_id> |
labels |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts labels |
links |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts links |
posts |
page? |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts posts |
post-detail |
slug |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts post-detail <slug> |
comments |
slug |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts comments <slug> |
thread |
slug |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts thread <slug> |
members |
page? |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts members |
modules |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts modules |
leaderboards |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts leaderboards |
calendar |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts calendar |
chats |
limit? |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts chats |
messages |
channel_id |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts messages <channel_id> |
questions |
page? |
read |
npx tsx ~/.claude/skills/snappy-skool/api.ts questions |
brief |
— | read |
npx tsx ~/.claude/skills/snappy-skool/api.ts brief |
post |
title, body, category? |
post |
npx tsx ~/.claude/skills/snappy-skool/api.ts post "<title>" "<body>" |
comment |
post, body |
post |
npx tsx ~/.claude/skills/snappy-skool/api.ts comment <post> "<body>" |
dm |
channel_id, body |
send |
npx tsx ~/.claude/skills/snappy-skool/api.ts dm <channel_id> "<body>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-skool
role: Skool reader and governed community writer
loaded-by: preload-skill-context hook
Triggers on: snappy-skool, Skool, community, community post, member, classroom, leaderboard, DM
---
# snappy-skool Agent Loader
Use `api.ts` as the only operational road. Do not recreate Skool calls with curl, guessed endpoints, or browser clicks.
## Contract
```bash
npx tsx ~/.claude/skills/snappy-skool/api.ts contract
```
Reads run now. `post`, `comment`, and `dm` stage through `snappy-settings/stage.ts`. Never add `--now`; only the approval executor may do that.
| Need | Command |
|---|---|
| Cookie health | `npx tsx ~/.claude/skills/snappy-skool/api.ts health` |
| Cookie owner | `npx tsx ~/.claude/skills/snappy-skool/api.ts me --json` |
| Group census | `npx tsx ~/.claude/skills/snappy-skool/api.ts group` |
| Admins, levels, labels, links | `admins`, `levels`, `labels`, `links` |
| Classroom | `courses`, `course <id>`, `modules` |
| Feed | `posts [page]`, `post-detail <slug>`, `comments <slug>`, `questions [page]` |
| Members | `members [page]` |
| Other group reads | `leaderboards`, `calendar`, `brief` |
| Chats | `chats [limit]`, then `messages <channel-id>` |
| Stage content | `post <title> <body> [category]`, `comment <slug> <body>` |
| Stage DM | `dm <channel-id> <body>` |
Use `--group <slug>` for any Skool group. Default: `snappy`. Chats are account-scoped.
## `--json` on a read is the FACE's object
`posts --json` prints `skool-feed`, `post-detail --json` prints `skool-post`, and
`comments --json` (spelled `thread` when you want the shape word) prints
`skool-comments`. Each carries `kind`, and its other keys ARE that face's props
-- the community's own name and member count, the author's name and avatar, the
category's WORDS, the excerpt, the like and comment counts. Draw one with
`npx tsx ~/.claude/skills/snappy-faces/api.ts draw <kind> --data-file <file>`.
WITHOUT `--json` a read answers the hand's own object, which is where `id`,
`slug` and `permalink` live -- use that when you need a handle to go on with,
then `post-detail <slug>`. Every other read (`group`, `members`, `chats`,
`leaderboards`, …) prints its own answer under `--json` as it always did,
because no Skool face draws those shapes yet.
## Auth
Cookie jar: `~/.openclaw/workspace/skool-auth.json`.
- `auth_token` currently expires 2027-07-19 unless revoked.
- The headed refresh renews `aws-waf-token` for four days and `AWSALB*` for seven days.
- If `health` says `api2=true, site=false`, refresh the headed browser state. Do not ask for a password first.
- Never print cookie values.
Exact refresh commands and the sign-in fallback are in `SKILL.md` section 3.
## Endpoint discipline
The private API was measured from the live site's network log and current frontend bundle on 2026-09-08. Do not guess a path. If a captured path changes, stop and recapture before changing code.
The program uses:
- `api2.skool.com` for group, admin, levels, course, category, link, and chat JSON.
- Authenticated `www.skool.com` HTML `__NEXT_DATA__` for account, feed, post, member, classroom, leaderboard, and calendar reads.
- A fresh page `securityNonce` sent as `x-sk-sec` only after approval.
## Write governance
- `post` has effect `post`.
- `comment` has effect `post`.
- `dm` has effect `send`.
- A raw shell call stages too.
- If the stage door is unavailable, nothing was sent. Do not fall back to direct fetch or browser automation.
- Classroom and member write endpoints are documented research only. They are not contract verbs.
## StateChange
StateChange is `pro.statechange.ai`, not Skool. It uses a separate browser state and is not served by `--group`. No `snappy-statechange` alias exists.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-skool Index]|root: ~/.claude/skills/snappy-skool|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}
<!-- SKILL-INDEX-END -->
## Used by
- `snappy-course`
- `snappy-image`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `health` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts health` |
| `me` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts me` |
| `group` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts group` |
| `admins` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts admins` |
| `levels` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts levels` |
| `courses` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts courses` |
| `course` | `course_id` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts course <course_id>` |
| `labels` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts labels` |
| `links` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts links` |
| `posts` | `page?` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts posts` |
| `post-detail` | `slug` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts post-detail <slug>` |
| `comments` | `slug` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts comments <slug>` |
| `thread` | `slug` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts thread <slug>` |
| `members` | `page?` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts members` |
| `modules` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts modules` |
| `leaderboards` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts leaderboards` |
| `calendar` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts calendar` |
| `chats` | `limit?` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts chats` |
| `messages` | `channel_id` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts messages <channel_id>` |
| `questions` | `page?` | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts questions` |
| `brief` | — | `read` | `npx tsx ~/.claude/skills/snappy-skool/api.ts brief` |
| `post` | `title`, `body`, `category?` | `post` | `npx tsx ~/.claude/skills/snappy-skool/api.ts post "<title>" "<body>"` |
| `comment` | `post`, `body` | `post` | `npx tsx ~/.claude/skills/snappy-skool/api.ts comment <post> "<body>"` |
| `dm` | `channel_id`, `body` | `send` | `npx tsx ~/.claude/skills/snappy-skool/api.ts dm <channel_id> "<body>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
This skill operates Skool's private frontend API. Skool has no public developer API for these operations. Every endpoint below was verified on 2026-09-08 from the live www.skool.com/snappy application, its network log, its current JavaScript client, or a side-effect-free GET against the same cookie session.
The governing rule is simple:
post, comment, and dm stage through snappy-settings/stage.ts.--now and make the captured request.api.ts contract is the authoritative verb list. Tables marked "not exposed" are research records, not callable promises.bashnpx tsx skills/snappy-skool/api.ts contract
bash# Side-effect-free auth check
npx tsx skills/snappy-skool/api.ts health
# Who owns the cookie
npx tsx skills/snappy-skool/api.ts me --json
# Core reads
npx tsx skills/snappy-skool/api.ts group
npx tsx skills/snappy-skool/api.ts posts
npx tsx skills/snappy-skool/api.ts members
npx tsx skills/snappy-skool/api.ts modules
npx tsx skills/snappy-skool/api.ts chats
# Draft outward actions. These stage and do not send.
npx tsx skills/snappy-skool/api.ts post "Title" "Body" "Category"
npx tsx skills/snappy-skool/api.ts comment "post-slug" "Reply body"
npx tsx skills/snappy-skool/api.ts dm "channel-id" "Message body"
All commands accept --json. Group-scoped commands accept --group <slug>.
The credential is a Playwright storage-state file:
~/.openclaw/workspace/skool-auth.json
The program reads cookie values from that file and sends them in a Cookie header. Values must never be printed, logged, committed, or copied into fixtures.
Two cookie layers matter:
| Cookie | Purpose | Measured expiry after the 2026-09-08 refresh |
|---|---|---|
auth_token |
Account authentication for api2.skool.com |
2027-07-19T05:01:41.435Z; not extended by the WAF refresh |
aws-waf-token |
Allows plain reads from www.skool.com |
Exactly four days after the headed refresh, 2026-09-12T23:16:39Z |
AWSALB* |
www.skool.com load-balancer affinity |
Exactly seven days after the headed refresh, 2026-09-15T23:16:37Z |
Operationally, the jar's shortest required lifetime is four days. Refresh it before the WAF token expires, or immediately if health reports site=false. The server can revoke any cookie earlier.
The /opt/homebrew/bin/agent-browser symlink was not executable tonight. The installed Node wrapper was used directly:
bashAB="/opt/homebrew/lib/node_modules/agent-browser/bin/agent-browser.js"
STATE="$HOME/.openclaw/workspace/skool-auth.json"
SESSION="skool-auth-refresh"
node "$AB" --session "$SESSION" --headed --state "$STATE" open https://www.skool.com/snappy
node "$AB" --session "$SESSION" wait 6000
node "$AB" --session "$SESSION" get title
node "$AB" --session "$SESSION" snapshot -i -c --max-output 1200
# Confirm the title is "Snappy Community" and the account button is visible.
node "$AB" --session "$SESSION" state save "$STATE"
npx tsx skills/snappy-skool/api.ts health --json
If the headed window shows a sign-in screen, sign in manually in that window, wait for the group feed, verify the account button, and then run state save. Do not automate password entry and do not print the storage state.
health is side-effect free. It performs a group GET on api2.skool.com, reads the group page from www.skool.com, and reports both cookie expiry clocks.
The default group is snappy. Every group-scoped read and staged content write supports another Skool group:
bashnpx tsx skills/snappy-skool/api.ts group --group another-group
npx tsx skills/snappy-skool/api.ts posts 2 --group another-group
npx tsx skills/snappy-skool/api.ts post "Title" "Body" --group another-group
The skill resolves the group's internal ID from GET /groups/{slug}. It does not hardcode the Snappy group ID. A call is accepted only when the returned slug matches the requested slug.
Chats are account-scoped, not group-scoped. chats returns channel IDs. messages and dm take a channel ID.
| Verb | Live request | Returned shape |
|---|---|---|
health |
GET /groups/{slug} plus authenticated group HTML |
{ok, group, api2, site, cookies, checked_at} |
me |
Group HTML __NEXT_DATA__.props.pageProps.self |
{ok, account:{id,handle,first_name,last_name,email,time_zone},group} |
group |
GET https://api2.skool.com/groups/{slug} |
{id,slug,display_name,description,total_members,total_admins,privacy,created_at,verified_scope} |
admins |
GET /groups/{slug}/admins |
[{id,handle,role}] |
levels |
GET /groups/{slug}/levels |
[{number,name,title,percent_of_members,unlocks_courses}] |
courses |
GET /groups/{slug}/courses |
Course array as served by Skool |
course <id> |
GET /courses/{courseId} |
One course tree, including its children |
labels |
GET /groups/{slug}/labels |
Category array |
links |
GET /groups/{slug}/links |
Sidebar-link array, maximum three in the current product |
posts [page] |
Group HTML. Client navigation uses GET /_next/data/{build}/{slug}.json?group={slug}&p={page} |
{group,page,total,posts:[{id,slug,title,content,author,author_id,created_at,comment_count,like_count,pinned,permalink}]} |
post-detail <slug> |
Post HTML plus the captured comments request below | One post plus comments[], pinned_comments[], and comments_cursor |
comments <slug> |
GET /posts/{id}/comments?group-id={groupId}&limit=25&pinned=true after resolving IDs from the post page |
{post_id,group,comments,pinned_comments,first,last} |
members [page] |
Members HTML. Client navigation uses GET /_next/data/{build}/{slug}/-/members.json?group={slug}&p={page} |
{group,page,total,total_pages,items_per_page,members:[account-shape]} |
modules |
Classroom HTML. Client navigation uses GET /_next/data/{build}/{slug}/classroom.json?group={slug} |
[{id,name,title,description,cover_image,unit_type,state,public,created_at}] |
leaderboards |
GET /_next/data/{build}/{slug}/-/leaderboards.json?group={slug} |
{group,all_time,past_7_days,past_30_days,self,levels}; each period has {type,offset,limit,updatedAt,users} |
calendar |
GET /_next/data/{build}/{slug}/calendar.json?group={slug} |
{group,events,total} |
chats [limit] |
GET /self/chat-channels?offset=0&limit={n}&last=&unread-only=false |
{channels:[...]} |
messages <channel> |
GET /channels/{channelId}/messages?before=35&after=35&msg={lastMessageId} after resolving last_message_id from chats |
{messages,has_more_before,has_more_after,channel} |
questions [page] |
Composite over posts |
{group,page,scanned,unanswered_count,question_count,rows} |
brief |
Composite over group, admins, levels, courses, and questions |
{ok,group,admins,levels,classroom,queue,read_at} |
total, page, postTrees, self, currentGroup, and securityNonce.users per page plus total, totalPages, itemsPerPage, courses, and filter state.allCourses.{channels}.{messages, has_more_before, has_more_after, channel}.{id,name,metadata,createdAt,updatedAt,email,firstName,lastName,timeZone,member,...}. The skill intentionally returns only the compact account shape above.The current frontend mints a one-use securityNonce into page props. Approved writes send it as x-sk-sec. The skill fetches a fresh nonce immediately before an approved request.
post#Effect class: post. Default behavior: stage.
bashnpx tsx skills/snappy-skool/api.ts post "Title" "Body" "Exact category name" --group snappy
Approved request shape captured from the live client:
httpPOST https://api2.skool.com/posts?follow=true
x-sk-sec: <fresh page nonce>
content-type: application/json
{
"post_type": "generic",
"group_id": "<resolved group id>",
"metadata": {
"title": "...",
"content": "...",
"labels": "<resolved exact category id, omitted when absent>"
}
}
The full live client can also include root_id, parent_id, attachments, video links, poll ID, action flag, video IDs, notify, and follow. This skill exposes only the minimal text-post subset above.
comment#Effect class: post. Default behavior: stage.
bashnpx tsx skills/snappy-skool/api.ts comment "post-slug" "Comment body" --group snappy
Approved request shape:
httpPOST https://api2.skool.com/posts?follow=false
x-sk-sec: <fresh page nonce>
content-type: application/json
{
"post_type": "comment",
"group_id": "<resolved group id>",
"root_id": "<resolved root post id>",
"parent_id": "<resolved root post id>",
"metadata": { "content": "..." }
}
dm#Effect class: send. Default behavior: stage. Obtain the channel ID from chats.
bashnpx tsx skills/snappy-skool/api.ts dm "channel-id" "Message body"
Approved desktop-chat request shape:
httpPOST https://api2.skool.com/channels/{channelId}/messages?ct=wdc
x-sk-sec: <fresh page nonce>
content-type: application/json
{ "content": "..." }
The live UI uses wdc for desktop chat and can additionally send attachments and video_ids. This skill exposes text DMs only.
These methods exist in the current live frontend bundle. They are deliberately absent from HAND_CONTRACT, so they must not be called through this skill until each receives a staged verb, validation, and an approval receipt.
| Operation | Captured request shape | Effect |
|---|---|---|
| Create course/module | POST /courses body {group_id,user_id,parent_id,root_id,unit_type,state,amount,has_annual_unlock,is_afl_comp_eligible,metadata:{title,desc,cover_image,cover_image_file,video_link,transcript,resources,video_len_ms,video_thumbnail,video_stream,privacy,min_access_level,drip_config,min_tier,lock_free_trial}} |
write |
| Update course/module | PUT /courses/{id} with the corresponding editable fields |
write |
| Delete course/module | DELETE /courses/{id} with email verification |
delete |
| Reorder | POST /courses/{id}/move2?dst={ordinal} |
write |
| Drip | POST /courses/{id}/update-drip body {enabled,days} |
write |
| Trial lock | POST /courses/{id}/lock-free-trial body {enabled} |
write |
| Member unlock | POST /courses/{id}/unlock?member={memberId} |
write |
| Publish/archive state | PUT /courses/{id}/state?state={state} |
write or delete when archiving |
| Move parent | POST /courses/{id}/update-parent?parent={parentId} |
write |
| Duplicate | POST /courses/{id}/duplicate |
write |
| Create share key | POST /courses/{id}/clone-code |
write |
| Import | POST /courses/{id}/import?group={groupId}&clone_code={code} |
write |
These are also research records only.
| Operation | Captured request shape | Effect |
|---|---|---|
| Change role | POST /members/{id}/role body {new} |
write |
| Verify/unverify | PUT /members/{id}/verification body {verified} |
write |
| Delete recent posts | POST /groups/{groupId}/delete-user-recent-posts?user={userId} |
delete |
| Grant/revoke course permissions | POST /groups/{groupId}/update-member-course-permission body {member_id,grant,revoke} |
write |
| Bulk member action | POST /groups/{groupId}/request-bulk-action?type={type}&all-members-selected={bool}&member-status={status} with filter body |
write, or delete for removals |
| Bulk invite | POST /groups/{groupName}/bulk-invite body {file_id,course_ids} |
send |
| Cancel member billing | POST /groups/{groupId}/cancel-member-billing-subscription?member={memberId} with email verification |
delete |
| Minimum chat level | POST /groups/{id}/enable-min-chat-level, POST .../disable-min-chat-level, PUT .../min-chat-level?level={n} |
write |
| Minimum post level | POST /groups/{id}/enable-min-post-level, POST .../disable-min-post-level, PUT .../min-post-level?level={n} |
write |
| Failure | Meaning | Action |
|---|---|---|
needs-credential for auth_token |
Cookie jar missing or account cookie expired | Run the headed sign-in and save ritual |
api2=true, site=false |
Account cookie works but WAF cookie expired | Run the headed refresh ritual; no password is normally needed |
site page has no __NEXT_DATA__ or no self |
Sign-in wall or WAF challenge | Refresh the state and rerun health |
No exact Skool category match |
Category name or ID did not exactly match live labels | Run labels, choose one exact value, and restage |
stage_door_unreachable |
Local approval daemon did not accept the action | Nothing was sent. Restore the daemon and stage again |
AUTH-SEC-501 or security verification refusal |
Fresh nonce required or endpoint gating changed | Do not retry a write loop. Recapture the frontend and update this manual/code |
Never fall back from a failed staged write to browser clicks or direct curl. One action has one governed road.
StateChange is not a Skool group. The repository's current StateChange road points to:
https://pro.statechange.ai/messages/08dbd6f1-688f-45ac-b671-4e89d7c75279
It is the separate pro.statechange.ai platform with its own statechange-auth.json browser state. Therefore snappy-skool --group does not serve it, and no snappy-statechange alias skill was created.
Measured on 2026-09-08 in a headed authenticated session:
Snappy Community.pageProps.self.GET /self/chat-channels.GET /channels/{id}/messages without sending.---
name: snappy-skool
reports_to: growth
head: false
description: >
Master operating manual and typed API for Skool communities. Reads the live
Skool account, groups, feed, posts, members, classroom, leaderboards,
calendar, and chats. Posts, comments, and DMs are staged for approval.
triggers:
- snappy-skool
- skool
- community
- snappy community
- skool community
- mastermind
- member
- classroom
- leaderboard
- community post
- skool dm
aliases:
- community
- skool
---
# Snappy Skool
## Table of Contents
1. [Contract and safety](#1-contract-and-safety)
2. [Quick start](#2-quick-start)
3. [Authentication and cookie lifetime](#3-authentication-and-cookie-lifetime)
4. [Group selection](#4-group-selection)
5. [Read endpoints and response shapes](#5-read-endpoints-and-response-shapes)
6. [Staged writes](#6-staged-writes)
7. [Captured classroom writes not exposed](#7-captured-classroom-writes-not-exposed)
8. [Captured member actions not exposed](#8-captured-member-actions-not-exposed)
9. [Failure handling](#9-failure-handling)
10. [StateChange measurement](#10-statechange-measurement)
11. [Verification record](#11-verification-record)
## 1. Contract and safety
This skill operates Skool's private frontend API. Skool has no public developer API for these operations. Every endpoint below was verified on 2026-09-08 from the live `www.skool.com/snappy` application, its network log, its current JavaScript client, or a side-effect-free GET against the same cookie session.
The governing rule is simple:
- Reads run immediately.
- `post`, `comment`, and `dm` stage through `snappy-settings/stage.ts`.
- Only the approval executor may append `--now` and make the captured request.
- Never call a write endpoint by hand to test it.
- `api.ts contract` is the authoritative verb list. Tables marked "not exposed" are research records, not callable promises.
```bash
npx tsx skills/snappy-skool/api.ts contract
```
## 2. Quick start
```bash
# Side-effect-free auth check
npx tsx skills/snappy-skool/api.ts health
# Who owns the cookie
npx tsx skills/snappy-skool/api.ts me --json
# Core reads
npx tsx skills/snappy-skool/api.ts group
npx tsx skills/snappy-skool/api.ts posts
npx tsx skills/snappy-skool/api.ts members
npx tsx skills/snappy-skool/api.ts modules
npx tsx skills/snappy-skool/api.ts chats
# Draft outward actions. These stage and do not send.
npx tsx skills/snappy-skool/api.ts post "Title" "Body" "Category"
npx tsx skills/snappy-skool/api.ts comment "post-slug" "Reply body"
npx tsx skills/snappy-skool/api.ts dm "channel-id" "Message body"
```
All commands accept `--json`. Group-scoped commands accept `--group <slug>`.
## 3. Authentication and cookie lifetime
### Auth model
The credential is a Playwright storage-state file:
`~/.openclaw/workspace/skool-auth.json`
The program reads cookie values from that file and sends them in a `Cookie` header. Values must never be printed, logged, committed, or copied into fixtures.
Two cookie layers matter:
| Cookie | Purpose | Measured expiry after the 2026-09-08 refresh |
|---|---|---|
| `auth_token` | Account authentication for `api2.skool.com` | `2027-07-19T05:01:41.435Z`; not extended by the WAF refresh |
| `aws-waf-token` | Allows plain reads from `www.skool.com` | Exactly four days after the headed refresh, `2026-09-12T23:16:39Z` |
| `AWSALB*` | `www.skool.com` load-balancer affinity | Exactly seven days after the headed refresh, `2026-09-15T23:16:37Z` |
Operationally, the jar's shortest required lifetime is four days. Refresh it before the WAF token expires, or immediately if `health` reports `site=false`. The server can revoke any cookie earlier.
### Exact refresh ritual on this Mac
The `/opt/homebrew/bin/agent-browser` symlink was not executable tonight. The installed Node wrapper was used directly:
```bash
AB="/opt/homebrew/lib/node_modules/agent-browser/bin/agent-browser.js"
STATE="$HOME/.openclaw/workspace/skool-auth.json"
SESSION="skool-auth-refresh"
node "$AB" --session "$SESSION" --headed --state "$STATE" open https://www.skool.com/snappy
node "$AB" --session "$SESSION" wait 6000
node "$AB" --session "$SESSION" get title
node "$AB" --session "$SESSION" snapshot -i -c --max-output 1200
# Confirm the title is "Snappy Community" and the account button is visible.
node "$AB" --session "$SESSION" state save "$STATE"
npx tsx skills/snappy-skool/api.ts health --json
```
If the headed window shows a sign-in screen, sign in manually in that window, wait for the group feed, verify the account button, and then run `state save`. Do not automate password entry and do not print the storage state.
`health` is side-effect free. It performs a group GET on `api2.skool.com`, reads the group page from `www.skool.com`, and reports both cookie expiry clocks.
## 4. Group selection
The default group is `snappy`. Every group-scoped read and staged content write supports another Skool group:
```bash
npx tsx skills/snappy-skool/api.ts group --group another-group
npx tsx skills/snappy-skool/api.ts posts 2 --group another-group
npx tsx skills/snappy-skool/api.ts post "Title" "Body" --group another-group
```
The skill resolves the group's internal ID from `GET /groups/{slug}`. It does not hardcode the Snappy group ID. A call is accepted only when the returned slug matches the requested slug.
Chats are account-scoped, not group-scoped. `chats` returns channel IDs. `messages` and `dm` take a channel ID.
## 5. Read endpoints and response shapes
### Executable read verbs
| Verb | Live request | Returned shape |
|---|---|---|
| `health` | `GET /groups/{slug}` plus authenticated group HTML | `{ok, group, api2, site, cookies, checked_at}` |
| `me` | Group HTML `__NEXT_DATA__.props.pageProps.self` | `{ok, account:{id,handle,first_name,last_name,email,time_zone},group}` |
| `group` | `GET https://api2.skool.com/groups/{slug}` | `{id,slug,display_name,description,total_members,total_admins,privacy,created_at,verified_scope}` |
| `admins` | `GET /groups/{slug}/admins` | `[{id,handle,role}]` |
| `levels` | `GET /groups/{slug}/levels` | `[{number,name,title,percent_of_members,unlocks_courses}]` |
| `courses` | `GET /groups/{slug}/courses` | Course array as served by Skool |
| `course <id>` | `GET /courses/{courseId}` | One course tree, including its children |
| `labels` | `GET /groups/{slug}/labels` | Category array |
| `links` | `GET /groups/{slug}/links` | Sidebar-link array, maximum three in the current product |
| `posts [page]` | Group HTML. Client navigation uses `GET /_next/data/{build}/{slug}.json?group={slug}&p={page}` | `{group,page,total,posts:[{id,slug,title,content,author,author_id,created_at,comment_count,like_count,pinned,permalink}]}` |
| `post-detail <slug>` | Post HTML plus the captured comments request below | One post plus `comments[]`, `pinned_comments[]`, and `comments_cursor` |
| `comments <slug>` | `GET /posts/{id}/comments?group-id={groupId}&limit=25&pinned=true` after resolving IDs from the post page | `{post_id,group,comments,pinned_comments,first,last}` |
| `members [page]` | Members HTML. Client navigation uses `GET /_next/data/{build}/{slug}/-/members.json?group={slug}&p={page}` | `{group,page,total,total_pages,items_per_page,members:[account-shape]}` |
| `modules` | Classroom HTML. Client navigation uses `GET /_next/data/{build}/{slug}/classroom.json?group={slug}` | `[{id,name,title,description,cover_image,unit_type,state,public,created_at}]` |
| `leaderboards` | `GET /_next/data/{build}/{slug}/-/leaderboards.json?group={slug}` | `{group,all_time,past_7_days,past_30_days,self,levels}`; each period has `{type,offset,limit,updatedAt,users}` |
| `calendar` | `GET /_next/data/{build}/{slug}/calendar.json?group={slug}` | `{group,events,total}` |
| `chats [limit]` | `GET /self/chat-channels?offset=0&limit={n}&last=&unread-only=false` | `{channels:[...]}` |
| `messages <channel>` | `GET /channels/{channelId}/messages?before=35&after=35&msg={lastMessageId}` after resolving `last_message_id` from `chats` | `{messages,has_more_before,has_more_after,channel}` |
| `questions [page]` | Composite over `posts` | `{group,page,scanned,unanswered_count,question_count,rows}` |
| `brief` | Composite over `group`, `admins`, `levels`, `courses`, and `questions` | `{ok,group,admins,levels,classroom,queue,read_at}` |
### Live response shapes measured tonight
- Feed page props contain `total`, `page`, `postTrees`, `self`, `currentGroup`, and `securityNonce`.
- Members page props contain 30 `users` per page plus `total`, `totalPages`, `itemsPerPage`, `courses`, and filter state.
- Classroom page props contain `allCourses`.
- Chat-list JSON is `{channels}`.
- Message-thread JSON is `{messages, has_more_before, has_more_after, channel}`.
- The account object is `{id,name,metadata,createdAt,updatedAt,email,firstName,lastName,timeZone,member,...}`. The skill intentionally returns only the compact account shape above.
## 6. Staged writes
The current frontend mints a one-use `securityNonce` into page props. Approved writes send it as `x-sk-sec`. The skill fetches a fresh nonce immediately before an approved request.
### `post`
Effect class: `post`. Default behavior: stage.
```bash
npx tsx skills/snappy-skool/api.ts post "Title" "Body" "Exact category name" --group snappy
```
Approved request shape captured from the live client:
```http
POST https://api2.skool.com/posts?follow=true
x-sk-sec: <fresh page nonce>
content-type: application/json
{
"post_type": "generic",
"group_id": "<resolved group id>",
"metadata": {
"title": "...",
"content": "...",
"labels": "<resolved exact category id, omitted when absent>"
}
}
```
The full live client can also include `root_id`, `parent_id`, attachments, video links, poll ID, action flag, video IDs, `notify`, and `follow`. This skill exposes only the minimal text-post subset above.
### `comment`
Effect class: `post`. Default behavior: stage.
```bash
npx tsx skills/snappy-skool/api.ts comment "post-slug" "Comment body" --group snappy
```
Approved request shape:
```http
POST https://api2.skool.com/posts?follow=false
x-sk-sec: <fresh page nonce>
content-type: application/json
{
"post_type": "comment",
"group_id": "<resolved group id>",
"root_id": "<resolved root post id>",
"parent_id": "<resolved root post id>",
"metadata": { "content": "..." }
}
```
### `dm`
Effect class: `send`. Default behavior: stage. Obtain the channel ID from `chats`.
```bash
npx tsx skills/snappy-skool/api.ts dm "channel-id" "Message body"
```
Approved desktop-chat request shape:
```http
POST https://api2.skool.com/channels/{channelId}/messages?ct=wdc
x-sk-sec: <fresh page nonce>
content-type: application/json
{ "content": "..." }
```
The live UI uses `wdc` for desktop chat and can additionally send `attachments` and `video_ids`. This skill exposes text DMs only.
## 7. Captured classroom writes not exposed
These methods exist in the current live frontend bundle. They are deliberately absent from `HAND_CONTRACT`, so they must not be called through this skill until each receives a staged verb, validation, and an approval receipt.
| Operation | Captured request shape | Effect |
|---|---|---|
| Create course/module | `POST /courses` body `{group_id,user_id,parent_id,root_id,unit_type,state,amount,has_annual_unlock,is_afl_comp_eligible,metadata:{title,desc,cover_image,cover_image_file,video_link,transcript,resources,video_len_ms,video_thumbnail,video_stream,privacy,min_access_level,drip_config,min_tier,lock_free_trial}}` | `write` |
| Update course/module | `PUT /courses/{id}` with the corresponding editable fields | `write` |
| Delete course/module | `DELETE /courses/{id}` with email verification | `delete` |
| Reorder | `POST /courses/{id}/move2?dst={ordinal}` | `write` |
| Drip | `POST /courses/{id}/update-drip` body `{enabled,days}` | `write` |
| Trial lock | `POST /courses/{id}/lock-free-trial` body `{enabled}` | `write` |
| Member unlock | `POST /courses/{id}/unlock?member={memberId}` | `write` |
| Publish/archive state | `PUT /courses/{id}/state?state={state}` | `write` or `delete` when archiving |
| Move parent | `POST /courses/{id}/update-parent?parent={parentId}` | `write` |
| Duplicate | `POST /courses/{id}/duplicate` | `write` |
| Create share key | `POST /courses/{id}/clone-code` | `write` |
| Import | `POST /courses/{id}/import?group={groupId}&clone_code={code}` | `write` |
## 8. Captured member actions not exposed
These are also research records only.
| Operation | Captured request shape | Effect |
|---|---|---|
| Change role | `POST /members/{id}/role` body `{new}` | `write` |
| Verify/unverify | `PUT /members/{id}/verification` body `{verified}` | `write` |
| Delete recent posts | `POST /groups/{groupId}/delete-user-recent-posts?user={userId}` | `delete` |
| Grant/revoke course permissions | `POST /groups/{groupId}/update-member-course-permission` body `{member_id,grant,revoke}` | `write` |
| Bulk member action | `POST /groups/{groupId}/request-bulk-action?type={type}&all-members-selected={bool}&member-status={status}` with filter body | `write`, or `delete` for removals |
| Bulk invite | `POST /groups/{groupName}/bulk-invite` body `{file_id,course_ids}` | `send` |
| Cancel member billing | `POST /groups/{groupId}/cancel-member-billing-subscription?member={memberId}` with email verification | `delete` |
| Minimum chat level | `POST /groups/{id}/enable-min-chat-level`, `POST .../disable-min-chat-level`, `PUT .../min-chat-level?level={n}` | `write` |
| Minimum post level | `POST /groups/{id}/enable-min-post-level`, `POST .../disable-min-post-level`, `PUT .../min-post-level?level={n}` | `write` |
## 9. Failure handling
| Failure | Meaning | Action |
|---|---|---|
| `needs-credential` for `auth_token` | Cookie jar missing or account cookie expired | Run the headed sign-in and save ritual |
| `api2=true`, `site=false` | Account cookie works but WAF cookie expired | Run the headed refresh ritual; no password is normally needed |
| `site` page has no `__NEXT_DATA__` or no `self` | Sign-in wall or WAF challenge | Refresh the state and rerun `health` |
| `No exact Skool category match` | Category name or ID did not exactly match live labels | Run `labels`, choose one exact value, and restage |
| `stage_door_unreachable` | Local approval daemon did not accept the action | Nothing was sent. Restore the daemon and stage again |
| `AUTH-SEC-501` or security verification refusal | Fresh nonce required or endpoint gating changed | Do not retry a write loop. Recapture the frontend and update this manual/code |
Never fall back from a failed staged write to browser clicks or direct curl. One action has one governed road.
## 10. StateChange measurement
StateChange is not a Skool group. The repository's current StateChange road points to:
`https://pro.statechange.ai/messages/08dbd6f1-688f-45ac-b671-4e89d7c75279`
It is the separate `pro.statechange.ai` platform with its own `statechange-auth.json` browser state. Therefore `snappy-skool --group` does not serve it, and no `snappy-statechange` alias skill was created.
## 11. Verification record
Measured on 2026-09-08 in a headed authenticated session:
- Group page title: `Snappy Community`.
- Authenticated account button was present.
- The live account object populated `pageProps.self`.
- Client navigation captured members, classroom, calendar, leaderboards, about, and feed Next-data requests.
- Opening the chat drawer captured `GET /self/chat-channels`.
- Opening an already-read thread captured `GET /channels/{id}/messages` without sending.
- Current frontend bundle supplied the exact post, comment, DM, classroom, and member-action request shapes above.
- No post, comment, DM, deletion, member mutation, or classroom mutation was executed.
#!/usr/bin/env npx tsx
/**
* snappy-skool/api.ts -- Read Skool directly and stage all outward effects.
*
* Cookie auth is loaded from ~/.openclaw/workspace/skool-auth.json. Reads use
* plain fetch against api2.skool.com or the live site's __NEXT_DATA__. Writes
* are never exported as direct functions: post, comment, and DM first pass
* through snappy-settings/stage.ts, while --now is reserved for the approval
* executor.
*/
import { existsSync, readFileSync, realpathSync } from "fs";
import { join } from "path";
import { env } from "../snappy-settings/load.ts";
import { stageHandOperation } from "../snappy-settings/stage.ts";
import { decisionInContext, standingDoors, type DecisionInContext } from "../hand-decision-face.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
/** ⟨R17, lane r17-2 2026-09-09⟩ 25 IS SKOOL'S OWN PAGE SIZE for a post's
* comments — the number this road has always sent in the vendor's own `limit`
* query word — so it is the ceiling `thread`/`comments` declare, and a
* caller's count goes INTO that word rather than being trimmed after. */
export const SKOOL_COMMENTS_PAGE = 25;
/** ⟨R17, 2026-09-09⟩ 35 IS SKOOL'S OWN CHAT WINDOW — the `before`/`after` page
* size this road has always sent. The read is anchored at the channel's LAST
* message, so `before` is the word that decides how much of the conversation
* comes back, and a caller's count goes straight into it. */
export const SKOOL_CHAT_PAGE = 35;
/** How deep `messages` digs for its channel; also the ceiling `chats` serves. */
export const SKOOL_CHATS_CEILING = 300;
const API_ORIGIN = "https://api2.skool.com";
const SITE_ORIGIN = "https://www.skool.com";
const DEFAULT_GROUP = "snappy";
const AUTH_STATE = join(env("HOME", false) || process.env.HOME || "", ".openclaw/workspace/skool-auth.json");
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/128 Safari/537.36";
type JsonObject = Record<string, any>;
type CookieRecord = {
name: string;
value: string;
domain: string;
expires?: number;
};
type AuthState = { cookies?: CookieRecord[] };
export const HAND_CONTRACT = {
skill: "snappy-skool",
/** THE ONE SENTENCE THIS HAND IS FOUND BY ⟨R6⟩ — the SAME words as
* SKILL.md's frontmatter, so the catalog an agent searches and the file a
* person reads can never say two different things about one hand. */
description: "Master operating manual and typed API for Skool communities. Reads the live Skool account, groups, feed, posts, members, classroom, leaderboards, calendar, and chats. Posts, comments, and DMs are staged for approval.",
managed: false,
requires: [] as string[],
platform: "skool",
/** EVERY WAY THIS HAND SAYS NO ⟨R33⟩, as a PROJECTION of the collection's
* one closed table — never a second table that can drift from it. Each row
* here is a condition this file's own code can actually reach; refusals.test.ts
* re-checks that evidence, because a declared code nothing emits is a branch
* the reader waits for and never sees. */
refusals: refusalTable("missing_argument", "unknown_verb", "upstream_error"),
verbs: {
health: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
me: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
group: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
admins: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
levels: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
courses: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
course: {
args: ["course_id"], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
"course_id": { type: "string", description: "The Skool course id, from a `courses` row's `id`" },
} },
},
labels: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
links: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
posts: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
"post-detail": {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
} },
},
comments: {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
} },
},
/** `thread` IS `comments`, SPELLED AS THE SHAPE ⟨2026-09-09⟩. The face join
* derives a face from the verb's own word, and "comments" folds onto
* `list` -- which is the FEED, not the conversation under one post. The
* old name stays for one release because callers already spell it; both
* run the same read and print the same `skool-comments` face. */
thread: {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
limit: limitSchema(SKOOL_COMMENTS_PAGE, "How many comments of that post to return", { default: SKOOL_COMMENTS_PAGE }),
} },
},
members: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
modules: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
leaderboards: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
calendar: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
/** ⟨R17, 2026-09-09⟩ THE BOUND WAS HALF-DECLARED — a default with no
* ceiling, which is a ceiling the caller cannot see. `listChats` pages
* Skool thirty at a time until it holds the count asked for, and 300 is
* the depth this road actually serves (`messages` reads that many to find
* its channel), so 300 is the number now stated. The legacy positional
* still works; `--limit` is the declared word. */
chats: {
args: ["limit?"], effect: "read", flags: { json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(SKOOL_CHATS_CEILING, "How many chat channels to return", { default: 30 }),
} },
},
messages: {
args: ["channel_id"], effect: "read", flags: { json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
"channel_id": { type: "string", description: "The Skool chat channel id, from a `chats` row's `id`" },
limit: limitSchema(SKOOL_CHAT_PAGE, "How many messages before the newest to return", { default: SKOOL_CHAT_PAGE }),
} },
},
questions: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
brief: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
post: {
args: ["title", "body", "category?"], effect: "post", flags: { group: "--group", json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
title: { type: "string", description: "The post's title, verbatim" },
body: { type: "string", description: "The post's body, verbatim" },
category: { type: "string", description: "The category label to file the post under, from a `labels` row; omit for the group's default" },
} },
},
comment: {
args: ["post", "body"], effect: "post", target: "post", flags: { group: "--group", json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
post: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the comment lands under it" },
body: { type: "string", description: "The comment's words, verbatim" },
} },
},
dm: {
args: ["channel_id", "body"], effect: "send", target: "channel_id", flags: { json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
"channel_id": { type: "string", description: "The Skool chat channel id, from a `chats` row's `id`" },
body: { type: "string", description: "The message's words, verbatim" },
} },
},
},
} as const;
function assertGroup(group: string): string {
if (!/^[a-z0-9][a-z0-9-]{1,79}$/i.test(group)) throw new Error(`Invalid Skool group slug: ${group}`);
return group;
}
function loadAuthState(): AuthState {
if (!AUTH_STATE || !existsSync(AUTH_STATE)) {
const error = new Error(`Skool cookie jar not found at ${AUTH_STATE}`) as Error & { body?: JsonObject };
error.body = { ok: false, error: "needs-credential", credential: "skool-auth.json", path: AUTH_STATE };
throw error;
}
return JSON.parse(readFileSync(AUTH_STATE, "utf8"));
}
function cookieHeader(): string {
const now = Date.now() / 1000;
const cookies = (loadAuthState().cookies || []).filter((cookie) =>
cookie.domain.endsWith("skool.com") && (!cookie.expires || cookie.expires < 0 || cookie.expires > now));
const auth = cookies.find((cookie) => cookie.name === "auth_token");
if (!auth) {
const error = new Error("Skool auth_token is absent or expired") as Error & { body?: JsonObject };
error.body = { ok: false, error: "needs-credential", credential: "auth_token", path: AUTH_STATE };
throw error;
}
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}
function cookieStatus(name: string) {
const cookie = (loadAuthState().cookies || []).find((item) => item.name === name);
const now = Date.now() / 1000;
return {
present: Boolean(cookie),
live: Boolean(cookie && (!cookie.expires || cookie.expires < 0 || cookie.expires > now)),
expires_at: cookie?.expires && cookie.expires > 0 ? new Date(cookie.expires * 1000).toISOString() : null,
seconds_remaining: cookie?.expires && cookie.expires > 0 ? Math.max(0, Math.round(cookie.expires - now)) : null,
};
}
function queryString(query: Record<string, string | number | boolean | undefined> = {}): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
const text = params.toString();
return text ? `?${text}` : "";
}
async function requestJson<T = any>(
path: string,
options: { method?: string; query?: Record<string, string | number | boolean | undefined>; body?: unknown; nonce?: string } = {},
): Promise<T> {
const method = options.method || "GET";
const url = `${API_ORIGIN}${path}${queryString(options.query)}`;
const headers: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
cookie: cookieHeader(),
origin: SITE_ORIGIN,
referer: `${SITE_ORIGIN}/`,
"user-agent": USER_AGENT,
};
if (options.nonce) headers["x-sk-sec"] = options.nonce;
const response = await fetch(url, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
let answer: any = text;
try { answer = text ? JSON.parse(text) : { ok: true, status: response.status }; } catch {}
if (!response.ok) {
const detail = typeof answer === "string" ? answer.slice(0, 500) : JSON.stringify(answer).slice(0, 500);
throw new Error(`Skool ${method} ${path} failed (${response.status}): ${detail}`);
}
return answer as T;
}
function parseNextData(html: string): JsonObject {
const match = html.match(/<script[^>]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
if (!match) throw new Error("Skool page carried no __NEXT_DATA__; refresh the headed browser cookie state");
return JSON.parse(match[1]);
}
async function pageProps(group: string, suffix = ""): Promise<JsonObject> {
assertGroup(group);
const response = await fetch(`${SITE_ORIGIN}/${group}${suffix}`, {
headers: { cookie: cookieHeader(), accept: "text/html", "user-agent": USER_AGENT },
signal: AbortSignal.timeout(30_000),
});
const html = await response.text();
if (!response.ok) throw new Error(`Skool page read failed (${response.status})`);
const props = parseNextData(html)?.props?.pageProps;
if (!props || !props.self) throw new Error("Skool page is not authenticated; refresh the headed browser cookie state");
return props;
}
function compactUser(user: any) {
return {
id: user?.id ?? null,
handle: user?.name ?? null,
first_name: user?.firstName ?? null,
last_name: user?.lastName ?? null,
email: user?.email ?? null,
time_zone: user?.timeZone ?? user?.metadata?.timezone ?? null,
};
}
function compactGroup(group: any, expectedSlug: string) {
const metadata = group?.metadata || {};
return {
id: group?.id ?? null,
slug: group?.name ?? null,
display_name: metadata.displayName ?? metadata.display_name ?? null,
description: metadata.description ?? null,
total_members: metadata.totalMembers ?? metadata.total_members ?? null,
total_admins: metadata.totalAdmins ?? metadata.total_admins ?? null,
privacy: metadata.privacy ?? null,
created_at: group?.createdAt ?? group?.created_at ?? null,
verified_scope: group?.name === expectedSlug,
};
}
export async function health(group = DEFAULT_GROUP) {
assertGroup(group);
let api2: JsonObject = { live: false };
let site: JsonObject = { live: false };
try {
const answer: any = await requestJson(`/groups/${group}`);
api2 = { live: answer?.name === group, group: answer?.name ?? null };
} catch (error) { api2 = { live: false, error: error instanceof Error ? error.message : String(error) }; }
try {
const props = await pageProps(group);
site = { live: Boolean(props.self?.id), authenticated: Boolean(props.self?.id) };
} catch (error) { site = { live: false, error: error instanceof Error ? error.message : String(error) }; }
let cookies: JsonObject;
try {
cookies = { auth_token: cookieStatus("auth_token"), aws_waf_token: cookieStatus("aws-waf-token") };
} catch {
cookies = {
auth_token: { present: false, live: false, expires_at: null, seconds_remaining: null },
aws_waf_token: { present: false, live: false, expires_at: null, seconds_remaining: null },
};
}
return {
ok: api2.live && site.live,
group,
api2,
site,
cookies,
checked_at: new Date().toISOString(),
};
}
export async function me(group = DEFAULT_GROUP) {
const props = await pageProps(group);
return { ok: true, account: compactUser(props.self), group };
}
export async function getGroup(group = DEFAULT_GROUP) {
return compactGroup(await requestJson(`/groups/${assertGroup(group)}`), group);
}
export async function listAdmins(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/admins`);
return (answer.admins || []).map((admin: any) => ({ id: admin.id, handle: admin.name, role: admin.metadata?.role ?? null }));
}
export async function listLevels(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/levels`);
return (answer.levels || []).map((level: any) => ({
number: level.number,
name: level.name,
title: level.title,
percent_of_members: level.percent_of_members ?? null,
unlocks_courses: (level.courses || []).length,
}));
}
export async function listCourses(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/courses`);
return answer.courses || [];
}
export async function getCourse(courseId: string) {
if (!courseId) throw new Error("course_id is required");
return requestJson(`/courses/${encodeURIComponent(courseId)}`);
}
export async function listLabels(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/labels`);
return answer.labels || [];
}
export async function listLinks(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/links`);
return answer.links || answer;
}
export async function listPosts(group = DEFAULT_GROUP, page = 1) {
const props = await pageProps(group, page > 1 ? `?p=${page}` : "");
return {
group,
page: props.page ?? page,
total: props.total ?? null,
// THE COMMUNITY'S OWN WORDS, not its slug ⟨2026-09-09⟩. The Skool feed face
// draws `communityName` and `memberCount` at its head, and this read is the
// only place that already holds them -- the page it parsed carries
// `currentGroup`. Without them the real feed drew "Community" over the
// owner's own group, which is the blank-field defect this lane is about.
community: communityWords(props.currentGroup, group),
posts: (props.postTrees || []).map((tree: any) => {
const post = tree.post || {};
return {
id: post.id,
slug: post.name,
title: post.metadata?.title ?? null,
content: post.metadata?.content ?? null,
author: person(post.user),
author_id: post.user?.id ?? null,
author_avatar: avatar(post.user),
// THE POST'S OWN PICTURES ⟨the owner, 2026-09-09 14:0x: "the images are
// not shown, which looks really bad"⟩. A feed page carries one per post;
// the whole set lives on the post page, so a feed row draws the first
// and opening the post draws them all.
images: skoolImagesOf(post.metadata),
// THE CATEGORY PILL comes from an id here and its WORDS from the
// group's label list; the face wants the words, so the id is carried
// and resolved once per read rather than looked up per post.
label_id: post.labelId ?? null,
created_at: post.createdAt ?? null,
comment_count: post.metadata?.comments ?? 0,
like_count: post.metadata?.upvotes ?? 0,
pinned: post.metadata?.pinned === true || post.metadata?.pinned === 1,
permalink: post.name ? `${SITE_ORIGIN}/${group}/${post.name}` : null,
};
}),
};
}
/** The group's name and size as the page states them, or the slug and null --
* never an invented count. */
function communityWords(currentGroup: any, group: string): { name: string; members: number | null } {
const metadata = currentGroup?.metadata || {};
const members = metadata.totalMembers ?? metadata.total_members ?? null;
return {
name: metadata.displayName ?? metadata.display_name ?? currentGroup?.name ?? group,
members: typeof members === "number" ? members : (members === null || members === undefined ? null : Number(members)),
};
}
export async function getPostDetail(slug: string, group = DEFAULT_GROUP) {
if (!slug) throw new Error("post slug is required");
const props = await pageProps(group, `/${encodeURIComponent(slug)}`);
const tree = props.postTree;
if (!tree?.post) return null;
const post = tree.post;
const comments = await commentsById(post.id, props.currentGroup?.id);
return {
id: post.id,
slug: post.name,
group,
community: communityWords(props.currentGroup, group),
title: post.metadata?.title ?? null,
content: post.metadata?.content ?? null,
author: person(post.user),
author_id: post.user?.id ?? null,
author_avatar: avatar(post.user),
// EVERY PICTURE, from `attachmentsData`, which only the post page carries.
images: skoolImagesOf(post.metadata),
label_id: post.labelId ?? null,
created_at: post.createdAt ?? null,
// THE COUNTS THE POST ALREADY CARRIES. The detail read dropped them, so a
// face over one post drew a post with no likes and no comments beside a
// feed row that had both.
comment_count: post.metadata?.comments ?? 0,
like_count: post.metadata?.upvotes ?? 0,
permalink: `${SITE_ORIGIN}/${group}/${post.name}`,
comments: comments.comments,
pinned_comments: comments.pinned_comments,
comments_cursor: comments.last,
};
}
/** THE COMMENTS API SPEAKS SNAKE_CASE ⟨MEASURED 2026-09-09⟩, and this reader
* asked it for camelCase. `first_name`, `last_name`, `created_at`,
* `picture_bubble` — every one of them answered undefined, so EVERY comment
* this hand has ever returned carried `author: ""` and `created_at: null`.
* It was invisible while the answer was prose; the moment a face drew it, the
* thread was a wall of unattributed text. Both spellings are read now, because
* the site's page road (`__NEXT_DATA__`) really does answer camelCase while
* api2 answers snake_case — one reader, two dialects, named. */
function person(user: any): string | null {
if (!user) return null;
const first = user.first_name ?? user.firstName ?? "";
const last = user.last_name ?? user.lastName ?? "";
const full = `${first} ${last}`.trim();
return full !== "" ? full : (user.name ?? null);
}
/** THE PICTURES A SKOOL POST CARRIES ⟨measured on www.skool.com 2026-09-09⟩.
*
* Skool puts a post's photographs in TWO places and the two roads carry
* different amounts, which is why this takes the metadata rather than the
* read:
*
* · THE FEED (`postTrees[].post.metadata`) carries `imagePreview` — ONE full
* size URL — plus `imagePreviewSmall`. That is all a feed page holds; there
* is no alt text anywhere in it.
* · THE POST PAGE (`postTree.post.metadata`) adds `attachmentsData`, a JSON
* STRING holding every attachment with `read_url`, `content_type` and the
* uploader's own `file_name`. So the one-post read draws the WHOLE set and
* the feed row draws the first picture — which is exactly what Skool's own
* feed does.
*
* `attachments` is NOT a road: it is a comma-joined list of attachment IDS,
* and a reader that mistook it for a URL would hand the face a broken image.
* A non-image attachment is dropped for the same reason — `Media` draws an
* `<img>`, and a PDF in that slot is a broken tile, not a document.
*
* Nothing found answers `undefined`, never `[]`: the key stays off the row and
* the face draws nothing rather than an empty frame. */
export function skoolImagesOf(metadata: any): { url: string; alt?: string }[] | undefined {
const meta = metadata || {};
const found: { url: string; alt?: string }[] = [];
const raw = typeof meta.attachmentsData === "string" ? meta.attachmentsData : "";
if (raw !== "") {
let rows: any[] = [];
try { rows = JSON.parse(raw); } catch { rows = []; }
for (const row of Array.isArray(rows) ? rows : []) {
const data = row?.metadata || {};
const type = typeof data.content_type === "string" ? data.content_type : "";
if (!type.startsWith("image/")) continue;
const url = [data.read_url, data.image_md_url, data.src_read_url]
.find((word: unknown) => typeof word === "string" && word.startsWith("http"));
if (typeof url !== "string") continue;
const alt = typeof data.file_name === "string" && data.file_name.trim() !== "" ? data.file_name : undefined;
found.push(alt === undefined ? { url } : { url, alt });
}
}
if (found.length > 0) return found;
const preview = meta.imagePreview ?? meta.image_preview;
if (typeof preview === "string" && preview.startsWith("http")) return [{ url: preview }];
return undefined;
}
function avatar(user: any): string | null {
const metadata = user?.metadata || {};
return metadata.picture_bubble ?? metadata.pictureBubble ?? metadata.pictureProfile ?? metadata.picture ?? null;
}
function compactCommentTree(child: any, depth = 0): any {
return {
id: child.post?.id,
content: child.post?.metadata?.content ?? null,
author: person(child.post?.user),
author_id: child.post?.user?.id ?? null,
author_avatar: avatar(child.post?.user),
created_at: child.post?.created_at ?? child.post?.createdAt ?? null,
like_count: child.post?.metadata?.upvotes ?? 0,
reply_count: child.children?.length || 0,
// ONE LEVEL DEEP, WHICH IS WHAT SKOOL DRAWS ⟨2026-09-09⟩. The comment
// thread face declares `replies` one level under their parent and the read
// answered a COUNT only, so every reply on the owner's own posts was
// invisible to any face over this hand. Depth is capped here rather than in
// the caller because the API's tree has no depth limit of its own.
replies: depth === 0 ? (child.children || []).map((grand: any) => compactCommentTree(grand, depth + 1)) : [],
};
}
async function commentsById(postId: string, groupId: string, limit = SKOOL_COMMENTS_PAGE) {
if (!groupId) throw new Error("Skool group id missing from post page");
const answer: any = await requestJson(`/posts/${encodeURIComponent(postId)}/comments`, {
query: { "group-id": groupId, limit, pinned: true },
});
return {
comments: (answer.post_tree?.children || []).map(compactCommentTree),
pinned_comments: (answer.pinned_post_tree?.children || []).map(compactCommentTree),
first: answer.first ?? null,
last: answer.last ?? null,
};
}
export async function listComments(slug: string, group = DEFAULT_GROUP, limit = SKOOL_COMMENTS_PAGE) {
if (!slug) throw new Error("post slug is required");
const props = await pageProps(group, `/${encodeURIComponent(slug)}`);
if (!props.postTree?.post?.id) throw new Error(`Skool post not found: ${slug}`);
return {
post_id: props.postTree.post.id,
group,
// WHICH POST, IN ITS OWN WORDS. The comment thread face heads itself with
// the post's title and the community's name; this read knew both and said
// neither, so the thread drew over an unnamed post.
post_title: props.postTree.post.metadata?.title ?? null,
community: communityWords(props.currentGroup, group),
...(await commentsById(props.postTree.post.id, props.currentGroup?.id, limit)),
};
}
export async function listMembers(group = DEFAULT_GROUP, page = 1) {
const props = await pageProps(group, `/-/members${page > 1 ? `?p=${page}` : ""}`);
return {
group,
page: props.page ?? page,
total: props.total ?? null,
total_pages: props.totalPages ?? null,
items_per_page: props.itemsPerPage ?? null,
members: (props.users || []).map(compactUser),
};
}
export async function listModules(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/classroom");
return (props.allCourses || []).map((course: any) => ({
id: course.id,
name: course.name,
title: course.metadata?.title ?? null,
description: course.metadata?.desc ?? null,
cover_image: course.metadata?.coverImage ?? course.metadata?.cover_image ?? null,
unit_type: course.unitType,
state: course.state,
public: course.public,
created_at: course.createdAt,
}));
}
export async function listLeaderboards(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/-/leaderboards");
return {
group,
all_time: props.allTime ?? null,
past_7_days: props.past7Days ?? null,
past_30_days: props.past30Days ?? null,
self: { all_time: props.selfAllTime ?? null, past_7_days: props.self7Days ?? null, past_30_days: props.self30Days ?? null },
levels: props.groupLevels || [],
};
}
export async function listCalendar(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/calendar");
return { group, events: props.events || [], total: props.events?.length ?? props.renderData?.numCalendarEvents ?? 0 };
}
export async function listChats(limit = 30) {
const channels: any[] = [];
while (channels.length < limit) {
const pageSize = Math.min(30, limit - channels.length);
const answer: any = await requestJson("/self/chat-channels", {
query: { offset: channels.length, limit: pageSize, last: "", "unread-only": false },
});
const page = answer.channels || [];
channels.push(...page);
if (page.length < 30) break;
}
return { channels: channels.slice(0, limit) };
}
export async function listSkoolMessages(channelId: string, limit = SKOOL_CHAT_PAGE) {
if (!channelId) throw new Error("channel_id is required");
const channel = (await listChats(SKOOL_CHATS_CEILING)).channels.find((item: any) => item.id === channelId);
if (!channel?.last_message_id) throw new Error(`Chat channel not found or empty: ${channelId}`);
const answer: any = await requestJson(`/channels/${encodeURIComponent(channelId)}/messages`, {
query: { before: limit, after: SKOOL_CHAT_PAGE, msg: channel.last_message_id },
});
return {
messages: answer.messages || [],
has_more_before: answer.has_more_before ?? false,
has_more_after: answer.has_more_after ?? false,
channel: answer.channel ?? null,
};
}
export async function listQuestions(group = DEFAULT_GROUP, page = 1) {
const feed = await listPosts(group, page);
const rows = feed.posts
.filter((post: any) => post.comment_count === 0)
.map((post: any) => ({ ...post, is_question: /\?/.test(`${post.title || ""} ${String(post.content || "").slice(0, 400)}`) }));
return {
group,
page: feed.page,
scanned: feed.posts.length,
unanswered_count: rows.length,
question_count: rows.filter((row: any) => row.is_question).length,
rows,
};
}
export async function brief(group = DEFAULT_GROUP) {
const [groupInfo, admins, levels, courses, queue] = await Promise.all([
getGroup(group), listAdmins(group), listLevels(group), listCourses(group), listQuestions(group, 1),
]);
return {
ok: groupInfo.verified_scope,
group: groupInfo,
admins,
levels,
classroom: { course_count: courses.length, courses },
queue,
read_at: new Date().toISOString(),
};
}
export async function stagePost(title: string, body: string, category: string | undefined, group = DEFAULT_GROUP) {
assertGroup(group);
return stageHandOperation({
skill: "snappy-skool",
verb: "post",
argv: [title, body, ...(category ? [category] : []), "--group", group],
fields: { title, body, category: category ?? null, group },
target: group,
facet: "content",
action_label: `Post to ${group} on Skool`,
risk: "medium",
});
}
export async function stageComment(post: string, body: string, group = DEFAULT_GROUP) {
assertGroup(group);
return stageHandOperation({
skill: "snappy-skool",
verb: "comment",
argv: [post, body, "--group", group],
fields: { post, body, group },
target: post,
facet: "content",
action_label: `Comment on ${post} in ${group}`,
risk: "medium",
});
}
export async function stageDm(channelId: string, body: string) {
return stageHandOperation({
skill: "snappy-skool",
verb: "dm",
argv: [channelId, body],
fields: { channel_id: channelId, body },
target: channelId,
facet: "chat-message",
action_label: "Send Skool DM",
risk: "medium",
});
}
async function securityNonce(group: string): Promise<string | undefined> {
const props = await pageProps(group);
return typeof props.securityNonce === "string" && props.securityNonce ? props.securityNonce : undefined;
}
async function resolveLabel(category: string | undefined, group: string): Promise<string | undefined> {
if (!category) return undefined;
const labels: any[] = await listLabels(group);
const needle = category.trim().toLowerCase();
const found = labels.find((label) =>
String(label.id).toLowerCase() === needle ||
String(label.name || label.metadata?.name || label.metadata?.displayName || label.metadata?.display_name || "").trim().toLowerCase() === needle);
if (!found) throw new Error(`No exact Skool category match for: ${category}`);
return found.id;
}
async function executePost(title: string, body: string, category: string | undefined, group: string) {
const [groupInfo, label, nonce] = await Promise.all([getGroup(group), resolveLabel(category, group), securityNonce(group)]);
const metadata: JsonObject = { title, content: body };
if (label) metadata.labels = label;
return requestJson("/posts", {
method: "POST",
query: { follow: true },
nonce,
body: { post_type: "generic", group_id: groupInfo.id, metadata },
});
}
async function executeComment(postSlug: string, body: string, group: string) {
const [post, groupInfo, nonce] = await Promise.all([getPostDetail(postSlug, group), getGroup(group), securityNonce(group)]);
if (!post?.id) throw new Error(`Skool post not found: ${postSlug}`);
return requestJson("/posts", {
method: "POST",
query: { follow: false },
nonce,
body: { post_type: "comment", group_id: groupInfo.id, root_id: post.id, parent_id: post.id, metadata: { content: body } },
});
}
async function executeDm(channelId: string, body: string) {
const props = await pageProps(DEFAULT_GROUP);
const nonce = typeof props.securityNonce === "string" ? props.securityNonce : undefined;
return requestJson(`/channels/${encodeURIComponent(channelId)}/messages`, {
method: "POST",
query: { ct: "wdc" },
nonce,
body: { content: body },
});
}
/* ── THE FACE THIS READ TAKES ────────────────────────────────────────────────
*
* MEASURED 2026-09-09 02:0x, through the MCP: `posts --json` printed the
* HAND'S OWN words -- {group, page, total, posts:[{id, slug, title, content,
* author, author_id, created_at, comment_count, like_count, pinned,
* permalink}]} -- and the Skool feed face declares {communityName, memberCount,
* posts:[{title, excerpt, authorName, labelName, postedAt, likesCount,
* commentsCount, pinned}]}. Two vocabularies for one set of posts, so the real
* feed drew with real titles and BLANK authors, blank excerpts, blank counts
* and the word "Community" where the owner's own group name belongs.
*
* SO `--json` PRINTS THE FACE'S OBJECT, not the hand's. One representation,
* and it is the face's, because the face is what a person sees. The verbs'
* ordinary (non-`--json`) answers are untouched -- they are what an agent
* reads when it wants slugs and ids to go on with.
*
* AND IT NAMES ITS OWN KIND. The runner derives a face from the hand's family
* and the verb's word, and that derivation cannot reach `skool-comments` --
* "comments" folds onto `list`, which is the FEED. A hand that names its kind
* outranks the derivation (snappy-runner/src/face.ts, rule 1), so every answer
* here says which face it is and nothing is guessed over it. The extra key is
* stripped by the face's own zod props, so the same object draws unchanged.
*/
/** The two lines a feed row shows. Skool bodies carry markdown links and hard
* wraps; the face has room for a sentence or two, so the words are flattened
* and cut at a word boundary rather than mid-syllable. Never invented: a post
* with no body has no excerpt. */
/** SKOOL'S OWN MARKUP, FLATTENED TO WHAT SKOOL DRAWS ⟨2026-09-09⟩. A body comes
* back carrying `[@Robert Boulos](obj://user/8d20…)` for a mention and
* `[ol:1][li]` for a list; a face that printed those verbatim showed the
* reader an internal address where Skool shows a name. The WORDS are kept and
* the machinery is dropped -- nothing is invented and nothing is summarised. */
export function flattenSkoolMarkup(content: string): string {
return content
.replace(/\[([^\]]+)\]\((?:obj:\/\/|https?:\/\/)[^)]*\)/gu, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/\[(?:ol|ul):[^\]]*\]/gu, "")
.replace(/\[li\]/gu, "\n")
.replace(/\\([()[\]])/gu, "$1");
}
export function excerptOf(content: string | null | undefined, limit = 220): string | null {
if (typeof content !== "string" || content.trim() === "") return null;
const flat = flattenSkoolMarkup(content).replace(/\s+/gu, " ").trim();
if (flat.length <= limit) return flat;
const cut = flat.slice(0, limit);
const space = cut.lastIndexOf(" ");
return `${(space > limit * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The category's WORDS from its id, using the group's own label list. A label
* the list does not name answers null -- an id in a pill is not a category. */
export function labelWordsFor(labelId: string | null | undefined, labels: any[]): string | null {
if (!labelId) return null;
const found = (labels || []).find((label: any) => label?.id === labelId);
return found?.metadata?.display_name ?? found?.metadata?.displayName ?? found?.name ?? null;
}
/** `posts` → the `skool-feed` face. */
export function skoolFeedFace(answer: any, labels: any[] = []): Record<string, unknown> {
return {
kind: "skool-feed",
posts: (answer?.posts || []).map((post: any) => ({
// THE ROW CARRIES THE WORD THE NEXT VERB TAKES ⟨2026-09-09⟩. `post-detail`
// and `comments` both take a SLUG, and this mapper dropped it — so a feed
// face could be drawn and not one of its rows could be opened. A list
// whose rows are dead ends is not a list of anything.
slug: post.slug ?? null,
title: post.title ?? post.slug ?? "(untitled post)",
excerpt: excerptOf(post.content),
authorName: post.author || null,
authorAvatarUrl: post.author_avatar || null,
images: post.images ?? undefined,
labelName: labelWordsFor(post.label_id, labels),
postedAt: post.created_at ?? null,
likesCount: typeof post.like_count === "number" ? post.like_count : null,
commentsCount: typeof post.comment_count === "number" ? post.comment_count : null,
pinned: post.pinned === true,
})),
communityName: answer?.community?.name ?? null,
memberCount: answer?.community?.members ?? null,
// HOW MANY POSTS THE COMMUNITY HOLDS ⟨lane faces-families, 2026-09-09⟩. The
// read has printed `total` at the top level since it was written and this
// fold dropped it, so the face could say "3 posts" over a community with
// 87 and nothing could tell the two apart ⟨the owner, 01:5x: "20, not
// three"⟩.
total: typeof answer?.total === "number" ? answer.total : null,
};
}
/** `post-detail` → the `skool-post` face. */
export function skoolPostFace(detail: any, labels: any[] = []): Record<string, unknown> {
return {
kind: "skool-post",
title: detail?.title ?? detail?.slug ?? "(untitled post)",
body: typeof detail?.content === "string" ? flattenSkoolMarkup(detail.content) : "",
communityName: detail?.community?.name ?? null,
authorName: detail?.author || null,
// THE AUTHOR'S FACE, WHICH THIS FOLD USED TO COMPUTE AND THROW AWAY. The
// read has fetched `author_avatar` on every post since the feed fold was
// written; the one-post face simply never passed it on, so the owner's own
// post drew an empty grey disc over his own name ⟨2026-09-09 13:4x⟩.
authorAvatarUrl: detail?.author_avatar || null,
images: detail?.images ?? undefined,
labelName: labelWordsFor(detail?.label_id, labels),
postedAt: detail?.created_at ?? null,
likesCount: typeof detail?.like_count === "number" ? detail.like_count : null,
commentsCount: typeof detail?.comment_count === "number" ? detail.comment_count : null,
};
}
/** `comments` (and its `thread` spelling) → the `skool-comments` face. Pinned
* comments lead, because that is the order Skool draws them in. */
export function skoolCommentsFace(answer: any): Record<string, unknown> {
const row = (comment: any) => ({
author: comment?.author || "Unknown member",
text: typeof comment?.content === "string" ? flattenSkoolMarkup(comment.content) : "",
likes: typeof comment?.like_count === "number" ? comment.like_count : null,
postedAt: comment?.created_at ?? null,
avatarUrl: comment?.author_avatar || null,
replies: (comment?.replies || []).map((reply: any) => ({
author: reply?.author || "Unknown member",
text: typeof reply?.content === "string" ? flattenSkoolMarkup(reply.content) : "",
likes: typeof reply?.like_count === "number" ? reply.like_count : null,
postedAt: reply?.created_at ?? null,
avatarUrl: reply?.author_avatar || null,
})),
});
return {
kind: "skool-comments",
comments: [...(answer?.pinned_comments || []), ...(answer?.comments || [])].map(row),
postTitle: answer?.post_title ?? null,
communityName: answer?.community?.name ?? null,
};
}
/** THE COMMENT IN THE CONVERSATION IT JOINS ⟨the owner's shape law, 2026-09-09
* 01:5x: "comments … the context; this is critical"⟩.
*
* `thread` is the SAME rows `skoolCommentsFace` prints, replies and all.
*
* AND THE DRAFT NOW HAS A FACE ⟨lane faces-families, 2026-09-09⟩:
* `skool-comment` (`SkoolCommentComposer`), Skool's own comment box. Until it
* existed this printed `kind: null` and named the gap in `faceGap` rather than
* borrowing a neighbouring kind — honest, and what reached a person was the
* conversation and then nothing where the half they were deciding should have
* been. The kind is the same one whether or not there are rows: a comment IS
* the same object either way, and the thread band above it is what says which
* of the two this is ⟨`widget-entry.tsx` ThreadBand⟩. */
export function skoolCommentDecisionFace(input: {
thread: Record<string, unknown> | null; post: string; body: string; group: string;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
const rows = (input.thread?.comments ?? []) as Record<string, unknown>[];
return decisionInContext({
decisionKind: "skool-comment",
composeKind: "skool-comment",
threadKind: "skool-comments",
thread: rows,
// `body` beside `text` ⟨doors-everywhere⟩: `text` is the word the composer
// face will draw when it exists, `body` is the word `comment <post> <body>`
// takes, and the press is built from the contract's.
draft: { author: "you", text: input.body, post: input.post, community: input.group, body: input.body },
act: input.act,
doors: standingDoors(`posts the comment on ${input.post} in ${input.group} now`, "Comment"),
});
}
/** A NEW POST HAS NO CONVERSATION, and the kind says so: `skool-post` draws the
* post itself, which is exactly what is being decided. */
export function skoolPostDecisionFace(input: {
title: string; body: string; category?: string | null; group: string;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
return decisionInContext({
decisionKind: "skool-post",
composeKind: "skool-post",
threadKind: "skool-comments",
thread: [],
draft: {
title: input.title, body: input.body,
communityName: input.group, labelName: input.category ?? null,
...(input.category ? { category: input.category } : {}),
},
act: input.act,
doors: standingDoors(`publishes to ${input.group} on Skool now`, "Post"),
});
}
/** THE ONE PLACE a verb's answer becomes its face. Returns null for a read no
* Skool face draws -- `levels`, `links`, `health` -- and that answer prints as
* it always did, because a face nobody built is not a face to fake. */
async function faceForVerb(command: string, answer: any, group: string): Promise<Record<string, unknown> | null> {
if (command === "posts") return skoolFeedFace(answer, await labelsOrNone(group));
if (command === "post-detail") return answer === null ? null : skoolPostFace(answer, await labelsOrNone(group));
if (command === "comments" || command === "thread") return skoolCommentsFace(answer);
return null;
}
/** The label words cost one request; a read that cannot get them draws the
* posts WITHOUT category pills rather than failing the whole answer. */
async function labelsOrNone(group: string): Promise<any[]> {
try { return await listLabels(group); } catch { return []; }
}
/** A FLAG'S VALUE IS A WORD TOO ⟨MEASURED 2026-09-09⟩. The old filter dropped
* `--json` but not what follows it, and the daemon's own caller
* (`hand-run.ts` `argvFromFields`) spells a boolean flag as a PAIR:
* `argv.push(flag, String(value))`. So the MCP road sent `posts --json true`
* and the bare `true` became the PAGE -- `positiveInt("true", 1)` throws, and
* the read failed on the one road that matters. `--group` keeps its own slug
* rule; every other `--`-word takes a trailing `true`/`false` with it. */
/** ITS NAME IS THIS HAND'S OWN ⟨lane CONTRACTS PLATFORM, 2026-09-09⟩. Two hands
* exported one `positionalWords` and skill-check's X1 caught the collision; the
* fix taken then was to drop `export` from both, which left gmail's and skool's
* face.test.ts importing a name neither file published — two test FILES that
* could not load, and so could not fail out loud either. A hand-specific name
* is the fix that keeps both: the export is back, and no two hands publish the
* same word. */
export function skoolPositionalWords(raw: readonly string[], groupAt: number): string[] {
return raw.filter((word, index) =>
!word.startsWith("--")
&& !(groupAt >= 0 && index === groupAt + 1)
&& !((word === "true" || word === "false") && (raw[index - 1] ?? "").startsWith("--") && raw[index - 1] !== "--group"));
}
function positiveInt(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const number = Number.parseInt(value, 10);
if (!Number.isFinite(number) || number < 1) throw new Error(`Expected a positive integer, got: ${value}`);
return number;
}
function humanSummary(command: string, data: any): string {
switch (command) {
case "health": return `Skool cookie ${data.ok ? "live" : "not live"}; api2=${data.api2.live}; site=${data.site.live}`;
case "me": return `Authenticated as ${data.account.first_name || data.account.handle} (${data.account.handle})`;
case "group": return `${data.display_name || data.slug}: ${data.total_members ?? "?"} members, ${data.total_admins ?? "?"} admins`;
case "admins": case "levels": case "courses": case "labels": case "links": case "modules": return `${data.length} ${command}`;
case "course": return `1 course`;
case "posts": return `${data.posts.length} posts on page ${data.page}; ${data.total ?? "?"} total`;
case "post-detail": return data ? `1 post; ${data.comments.length} comments` : "0 posts";
case "comments": case "thread": return `${data.comments.length} comments; ${data.pinned_comments.length} pinned`;
case "members": return `${data.members.length} members on page ${data.page}; ${data.total ?? "?"} total`;
case "leaderboards": return `${(data.all_time?.users?.length || 0) + (data.past_7_days?.users?.length || 0) + (data.past_30_days?.users?.length || 0)} leaderboard rows across 3 periods`;
case "calendar": return `${data.events.length} calendar events`;
case "chats": return `${data.channels.length} chats`;
case "messages": return `${data.messages.length} messages`;
case "questions": return `${data.unanswered_count} unanswered posts; ${data.question_count} questions`;
case "brief": return `${data.group.total_members ?? "?"} members; ${data.admins.length} admins; ${data.classroom.course_count} courses; ${data.queue.unanswered_count} unanswered`;
case "post": case "comment": case "dm": return data.staged ? `Staged (${data.control_id})` : `Not staged: ${JSON.stringify(data.answer)}`;
default: return typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
}
function usage(): string {
return [
"Usage: npx tsx api.ts <verb> [args] [--group slug] [--json]",
"Reads: health me group admins levels courses course labels links posts post-detail comments (alias thread) members modules leaderboards calendar chats messages questions brief",
"Staged writes: post <title> <body> [category] | comment <post-slug> <body> | dm <channel-id> <body>",
].join("\n");
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const command = process.argv[2];
if (command === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
return;
}
// ⟨R17, 2026-09-09⟩ THE COUNT LEAVES ARGV FIRST, THROUGH THE ONE PARSE.
// `skoolPositionalWords` drops `--`-words but KEEPS the word behind one, so
// a `--limit 10` left in place would land 10 in the first positional — the
// post slug, the channel id, the page number. `takeLimit`
// (snappy-settings/read-limit.ts) removes the flag and its number together;
// every index below is computed on what it left. Each arm then reads the
// count back off the RAW words under ITS OWN ceiling, so a count outside a
// verb's bound is refused BY NAME rather than clamped in silence.
const rawWords = process.argv.slice(3);
const raw = takeLimit(rawWords, { maximum: SKOOL_CHATS_CEILING }).rest;
const json = raw.includes("--json");
const now = raw.includes("--now");
const groupAt = raw.indexOf("--group");
const group = groupAt >= 0 ? raw[groupAt + 1] : DEFAULT_GROUP;
if (groupAt >= 0 && !group) throw new Error("--group requires a slug");
const positional = skoolPositionalWords(raw, groupAt);
let answer: any;
switch (command) {
case "health": answer = await health(group); break;
case "me": answer = await me(group); break;
case "group": answer = await getGroup(group); break;
case "admins": answer = await listAdmins(group); break;
case "levels": answer = await listLevels(group); break;
case "courses": answer = await listCourses(group); break;
case "course": answer = await getCourse(positional[0]); break;
case "labels": answer = await listLabels(group); break;
case "links": answer = await listLinks(group); break;
case "posts": answer = await listPosts(group, positiveInt(positional[0], 1)); break;
case "post-detail": answer = await getPostDetail(positional[0], group); break;
case "comments": case "thread": {
const bound = takeLimit(rawWords, { maximum: SKOOL_COMMENTS_PAGE, default: SKOOL_COMMENTS_PAGE });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listComments(positional[0], group, bound.limit); break;
}
case "members": answer = await listMembers(group, positiveInt(positional[0], 1)); break;
case "modules": answer = await listModules(group); break;
case "leaderboards": answer = await listLeaderboards(group); break;
case "calendar": answer = await listCalendar(group); break;
case "chats": {
const bound = takeLimit(rawWords, { maximum: SKOOL_CHATS_CEILING, default: 30 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listChats(positiveInt(positional[0], bound.limit)); break;
}
case "messages": {
const bound = takeLimit(rawWords, { maximum: SKOOL_CHAT_PAGE, default: SKOOL_CHAT_PAGE });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listSkoolMessages(positional[0], bound.limit); break;
}
case "questions": answer = await listQuestions(group, positiveInt(positional[0], 1)); break;
case "brief": answer = await brief(group); break;
case "post": {
const [title, body, category] = positional;
if (!title || !body) throw new Error("post requires <title> <body> [category]");
// A PREVIEW TOUCHES NOTHING: nothing published, nothing staged.
if (json) { console.log(JSON.stringify(skoolPostDecisionFace({ title, body, category, group,
act: { verb: "post", args: HAND_CONTRACT.verbs.post.args } }), null, 2)); return; }
answer = now ? await executePost(title, body, category, group) : await stagePost(title, body, category, group);
break;
}
case "comment": {
const [post, body] = positional;
if (!post || !body) throw new Error("comment requires <post-slug> <body>");
if (json) {
// THE CONVERSATION IS READ FIRST. A comment shown without the thread
// it joins is exactly the thing the owner will not approve.
const joined = await listComments(post, group);
console.log(JSON.stringify(skoolCommentDecisionFace({
thread: skoolCommentsFace(joined), post, body, group,
act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
}), null, 2));
return;
}
answer = now ? await executeComment(post, body, group) : await stageComment(post, body, group);
break;
}
case "dm": {
const [channelId, body] = positional;
if (!channelId || !body) throw new Error("dm requires <channel-id> <body>");
answer = now ? await executeDm(channelId, body) : await stageDm(channelId, body);
break;
}
default:
console.log(usage());
process.exitCode = command ? 1 : 0;
return;
}
// `--json` IS THE FACE'S OBJECT where a Skool face draws this shape, and the
// hand's own answer everywhere else. See "THE FACE THIS READ TAKES" above.
const face = json ? await faceForVerb(command, answer, group) : null;
console.log(json ? JSON.stringify(face ?? answer, null, 2) : humanSummary(command, answer));
if (["post", "comment", "dm"].includes(command) && !now && !answer.staged) process.exitCode = 1;
})().catch((error: any) => {
const body = error?.body ?? { ok: false, error: "hand-failed", detail: String(error?.message ?? error).slice(0, 800) };
console.log(JSON.stringify(body, null, 2));
process.exitCode = 1;
});
}
#!/usr/bin/env npx tsx
/**
* snappy-skool/api.ts -- Read Skool directly and stage all outward effects.
*
* Cookie auth is loaded from ~/.openclaw/workspace/skool-auth.json. Reads use
* plain fetch against api2.skool.com or the live site's __NEXT_DATA__. Writes
* are never exported as direct functions: post, comment, and DM first pass
* through snappy-settings/stage.ts, while --now is reserved for the approval
* executor.
*/
import { existsSync, readFileSync, realpathSync } from "fs";
import { join } from "path";
import { env } from "../snappy-settings/load.ts";
import { stageHandOperation } from "../snappy-settings/stage.ts";
import { decisionInContext, standingDoors, type DecisionInContext } from "../hand-decision-face.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
import { limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
/** ⟨R17, lane r17-2 2026-09-09⟩ 25 IS SKOOL'S OWN PAGE SIZE for a post's
* comments — the number this road has always sent in the vendor's own `limit`
* query word — so it is the ceiling `thread`/`comments` declare, and a
* caller's count goes INTO that word rather than being trimmed after. */
export const SKOOL_COMMENTS_PAGE = 25;
/** ⟨R17, 2026-09-09⟩ 35 IS SKOOL'S OWN CHAT WINDOW — the `before`/`after` page
* size this road has always sent. The read is anchored at the channel's LAST
* message, so `before` is the word that decides how much of the conversation
* comes back, and a caller's count goes straight into it. */
export const SKOOL_CHAT_PAGE = 35;
/** How deep `messages` digs for its channel; also the ceiling `chats` serves. */
export const SKOOL_CHATS_CEILING = 300;
const API_ORIGIN = "https://api2.skool.com";
const SITE_ORIGIN = "https://www.skool.com";
const DEFAULT_GROUP = "snappy";
const AUTH_STATE = join(env("HOME", false) || process.env.HOME || "", ".openclaw/workspace/skool-auth.json");
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/128 Safari/537.36";
type JsonObject = Record<string, any>;
type CookieRecord = {
name: string;
value: string;
domain: string;
expires?: number;
};
type AuthState = { cookies?: CookieRecord[] };
export const HAND_CONTRACT = {
skill: "snappy-skool",
/** THE ONE SENTENCE THIS HAND IS FOUND BY ⟨R6⟩ — the SAME words as
* SKILL.md's frontmatter, so the catalog an agent searches and the file a
* person reads can never say two different things about one hand. */
description: "Master operating manual and typed API for Skool communities. Reads the live Skool account, groups, feed, posts, members, classroom, leaderboards, calendar, and chats. Posts, comments, and DMs are staged for approval.",
managed: false,
requires: [] as string[],
platform: "skool",
/** EVERY WAY THIS HAND SAYS NO ⟨R33⟩, as a PROJECTION of the collection's
* one closed table — never a second table that can drift from it. Each row
* here is a condition this file's own code can actually reach; refusals.test.ts
* re-checks that evidence, because a declared code nothing emits is a branch
* the reader waits for and never sees. */
refusals: refusalTable("missing_argument", "unknown_verb", "upstream_error"),
verbs: {
health: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
me: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
group: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
admins: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
levels: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
courses: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
course: {
args: ["course_id"], effect: "read", flags: { json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
"course_id": { type: "string", description: "The Skool course id, from a `courses` row's `id`" },
} },
},
labels: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
links: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
posts: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
"post-detail": {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
} },
},
comments: {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
} },
},
/** `thread` IS `comments`, SPELLED AS THE SHAPE ⟨2026-09-09⟩. The face join
* derives a face from the verb's own word, and "comments" folds onto
* `list` -- which is the FEED, not the conversation under one post. The
* old name stays for one release because callers already spell it; both
* run the same read and print the same `skool-comments` face. */
thread: {
args: ["slug"], effect: "read", flags: { group: "--group", json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
slug: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the last segment of the post's URL" },
limit: limitSchema(SKOOL_COMMENTS_PAGE, "How many comments of that post to return", { default: SKOOL_COMMENTS_PAGE }),
} },
},
members: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
modules: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
leaderboards: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
calendar: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
/** ⟨R17, 2026-09-09⟩ THE BOUND WAS HALF-DECLARED — a default with no
* ceiling, which is a ceiling the caller cannot see. `listChats` pages
* Skool thirty at a time until it holds the count asked for, and 300 is
* the depth this road actually serves (`messages` reads that many to find
* its channel), so 300 is the number now stated. The legacy positional
* still works; `--limit` is the declared word. */
chats: {
args: ["limit?"], effect: "read", flags: { json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(SKOOL_CHATS_CEILING, "How many chat channels to return", { default: 30 }),
} },
},
messages: {
args: ["channel_id"], effect: "read", flags: { json: "--json", limit: "--limit" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
"channel_id": { type: "string", description: "The Skool chat channel id, from a `chats` row's `id`" },
limit: limitSchema(SKOOL_CHAT_PAGE, "How many messages before the newest to return", { default: SKOOL_CHAT_PAGE }),
} },
},
questions: {
args: ["page?"], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
page: { type: "integer", description: "Which page of the feed to read, 1-based; each page is one Skool page", default: 1 },
} },
},
brief: {
args: [], effect: "read", flags: { group: "--group", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
},
post: {
args: ["title", "body", "category?"], effect: "post", flags: { group: "--group", json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
title: { type: "string", description: "The post's title, verbatim" },
body: { type: "string", description: "The post's body, verbatim" },
category: { type: "string", description: "The category label to file the post under, from a `labels` row; omit for the group's default" },
} },
},
comment: {
args: ["post", "body"], effect: "post", target: "post", flags: { group: "--group", json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
post: { type: "string", description: "The post's slug, from a `posts` row's `slug` — the comment lands under it" },
body: { type: "string", description: "The comment's words, verbatim" },
} },
},
dm: {
args: ["channel_id", "body"], effect: "send", target: "channel_id", flags: { json: "--json" },
class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: {
"channel_id": { type: "string", description: "The Skool chat channel id, from a `chats` row's `id`" },
body: { type: "string", description: "The message's words, verbatim" },
} },
},
},
} as const;
function assertGroup(group: string): string {
if (!/^[a-z0-9][a-z0-9-]{1,79}$/i.test(group)) throw new Error(`Invalid Skool group slug: ${group}`);
return group;
}
function loadAuthState(): AuthState {
if (!AUTH_STATE || !existsSync(AUTH_STATE)) {
const error = new Error(`Skool cookie jar not found at ${AUTH_STATE}`) as Error & { body?: JsonObject };
error.body = { ok: false, error: "needs-credential", credential: "skool-auth.json", path: AUTH_STATE };
throw error;
}
return JSON.parse(readFileSync(AUTH_STATE, "utf8"));
}
function cookieHeader(): string {
const now = Date.now() / 1000;
const cookies = (loadAuthState().cookies || []).filter((cookie) =>
cookie.domain.endsWith("skool.com") && (!cookie.expires || cookie.expires < 0 || cookie.expires > now));
const auth = cookies.find((cookie) => cookie.name === "auth_token");
if (!auth) {
const error = new Error("Skool auth_token is absent or expired") as Error & { body?: JsonObject };
error.body = { ok: false, error: "needs-credential", credential: "auth_token", path: AUTH_STATE };
throw error;
}
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}
function cookieStatus(name: string) {
const cookie = (loadAuthState().cookies || []).find((item) => item.name === name);
const now = Date.now() / 1000;
return {
present: Boolean(cookie),
live: Boolean(cookie && (!cookie.expires || cookie.expires < 0 || cookie.expires > now)),
expires_at: cookie?.expires && cookie.expires > 0 ? new Date(cookie.expires * 1000).toISOString() : null,
seconds_remaining: cookie?.expires && cookie.expires > 0 ? Math.max(0, Math.round(cookie.expires - now)) : null,
};
}
function queryString(query: Record<string, string | number | boolean | undefined> = {}): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
const text = params.toString();
return text ? `?${text}` : "";
}
async function requestJson<T = any>(
path: string,
options: { method?: string; query?: Record<string, string | number | boolean | undefined>; body?: unknown; nonce?: string } = {},
): Promise<T> {
const method = options.method || "GET";
const url = `${API_ORIGIN}${path}${queryString(options.query)}`;
const headers: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
cookie: cookieHeader(),
origin: SITE_ORIGIN,
referer: `${SITE_ORIGIN}/`,
"user-agent": USER_AGENT,
};
if (options.nonce) headers["x-sk-sec"] = options.nonce;
const response = await fetch(url, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
let answer: any = text;
try { answer = text ? JSON.parse(text) : { ok: true, status: response.status }; } catch {}
if (!response.ok) {
const detail = typeof answer === "string" ? answer.slice(0, 500) : JSON.stringify(answer).slice(0, 500);
throw new Error(`Skool ${method} ${path} failed (${response.status}): ${detail}`);
}
return answer as T;
}
function parseNextData(html: string): JsonObject {
const match = html.match(/<script[^>]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
if (!match) throw new Error("Skool page carried no __NEXT_DATA__; refresh the headed browser cookie state");
return JSON.parse(match[1]);
}
async function pageProps(group: string, suffix = ""): Promise<JsonObject> {
assertGroup(group);
const response = await fetch(`${SITE_ORIGIN}/${group}${suffix}`, {
headers: { cookie: cookieHeader(), accept: "text/html", "user-agent": USER_AGENT },
signal: AbortSignal.timeout(30_000),
});
const html = await response.text();
if (!response.ok) throw new Error(`Skool page read failed (${response.status})`);
const props = parseNextData(html)?.props?.pageProps;
if (!props || !props.self) throw new Error("Skool page is not authenticated; refresh the headed browser cookie state");
return props;
}
function compactUser(user: any) {
return {
id: user?.id ?? null,
handle: user?.name ?? null,
first_name: user?.firstName ?? null,
last_name: user?.lastName ?? null,
email: user?.email ?? null,
time_zone: user?.timeZone ?? user?.metadata?.timezone ?? null,
};
}
function compactGroup(group: any, expectedSlug: string) {
const metadata = group?.metadata || {};
return {
id: group?.id ?? null,
slug: group?.name ?? null,
display_name: metadata.displayName ?? metadata.display_name ?? null,
description: metadata.description ?? null,
total_members: metadata.totalMembers ?? metadata.total_members ?? null,
total_admins: metadata.totalAdmins ?? metadata.total_admins ?? null,
privacy: metadata.privacy ?? null,
created_at: group?.createdAt ?? group?.created_at ?? null,
verified_scope: group?.name === expectedSlug,
};
}
export async function health(group = DEFAULT_GROUP) {
assertGroup(group);
let api2: JsonObject = { live: false };
let site: JsonObject = { live: false };
try {
const answer: any = await requestJson(`/groups/${group}`);
api2 = { live: answer?.name === group, group: answer?.name ?? null };
} catch (error) { api2 = { live: false, error: error instanceof Error ? error.message : String(error) }; }
try {
const props = await pageProps(group);
site = { live: Boolean(props.self?.id), authenticated: Boolean(props.self?.id) };
} catch (error) { site = { live: false, error: error instanceof Error ? error.message : String(error) }; }
let cookies: JsonObject;
try {
cookies = { auth_token: cookieStatus("auth_token"), aws_waf_token: cookieStatus("aws-waf-token") };
} catch {
cookies = {
auth_token: { present: false, live: false, expires_at: null, seconds_remaining: null },
aws_waf_token: { present: false, live: false, expires_at: null, seconds_remaining: null },
};
}
return {
ok: api2.live && site.live,
group,
api2,
site,
cookies,
checked_at: new Date().toISOString(),
};
}
export async function me(group = DEFAULT_GROUP) {
const props = await pageProps(group);
return { ok: true, account: compactUser(props.self), group };
}
export async function getGroup(group = DEFAULT_GROUP) {
return compactGroup(await requestJson(`/groups/${assertGroup(group)}`), group);
}
export async function listAdmins(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/admins`);
return (answer.admins || []).map((admin: any) => ({ id: admin.id, handle: admin.name, role: admin.metadata?.role ?? null }));
}
export async function listLevels(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/levels`);
return (answer.levels || []).map((level: any) => ({
number: level.number,
name: level.name,
title: level.title,
percent_of_members: level.percent_of_members ?? null,
unlocks_courses: (level.courses || []).length,
}));
}
export async function listCourses(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/courses`);
return answer.courses || [];
}
export async function getCourse(courseId: string) {
if (!courseId) throw new Error("course_id is required");
return requestJson(`/courses/${encodeURIComponent(courseId)}`);
}
export async function listLabels(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/labels`);
return answer.labels || [];
}
export async function listLinks(group = DEFAULT_GROUP) {
const answer: any = await requestJson(`/groups/${assertGroup(group)}/links`);
return answer.links || answer;
}
export async function listPosts(group = DEFAULT_GROUP, page = 1) {
const props = await pageProps(group, page > 1 ? `?p=${page}` : "");
return {
group,
page: props.page ?? page,
total: props.total ?? null,
// THE COMMUNITY'S OWN WORDS, not its slug ⟨2026-09-09⟩. The Skool feed face
// draws `communityName` and `memberCount` at its head, and this read is the
// only place that already holds them -- the page it parsed carries
// `currentGroup`. Without them the real feed drew "Community" over the
// owner's own group, which is the blank-field defect this lane is about.
community: communityWords(props.currentGroup, group),
posts: (props.postTrees || []).map((tree: any) => {
const post = tree.post || {};
return {
id: post.id,
slug: post.name,
title: post.metadata?.title ?? null,
content: post.metadata?.content ?? null,
author: person(post.user),
author_id: post.user?.id ?? null,
author_avatar: avatar(post.user),
// THE POST'S OWN PICTURES ⟨the owner, 2026-09-09 14:0x: "the images are
// not shown, which looks really bad"⟩. A feed page carries one per post;
// the whole set lives on the post page, so a feed row draws the first
// and opening the post draws them all.
images: skoolImagesOf(post.metadata),
// THE CATEGORY PILL comes from an id here and its WORDS from the
// group's label list; the face wants the words, so the id is carried
// and resolved once per read rather than looked up per post.
label_id: post.labelId ?? null,
created_at: post.createdAt ?? null,
comment_count: post.metadata?.comments ?? 0,
like_count: post.metadata?.upvotes ?? 0,
pinned: post.metadata?.pinned === true || post.metadata?.pinned === 1,
permalink: post.name ? `${SITE_ORIGIN}/${group}/${post.name}` : null,
};
}),
};
}
/** The group's name and size as the page states them, or the slug and null --
* never an invented count. */
function communityWords(currentGroup: any, group: string): { name: string; members: number | null } {
const metadata = currentGroup?.metadata || {};
const members = metadata.totalMembers ?? metadata.total_members ?? null;
return {
name: metadata.displayName ?? metadata.display_name ?? currentGroup?.name ?? group,
members: typeof members === "number" ? members : (members === null || members === undefined ? null : Number(members)),
};
}
export async function getPostDetail(slug: string, group = DEFAULT_GROUP) {
if (!slug) throw new Error("post slug is required");
const props = await pageProps(group, `/${encodeURIComponent(slug)}`);
const tree = props.postTree;
if (!tree?.post) return null;
const post = tree.post;
const comments = await commentsById(post.id, props.currentGroup?.id);
return {
id: post.id,
slug: post.name,
group,
community: communityWords(props.currentGroup, group),
title: post.metadata?.title ?? null,
content: post.metadata?.content ?? null,
author: person(post.user),
author_id: post.user?.id ?? null,
author_avatar: avatar(post.user),
// EVERY PICTURE, from `attachmentsData`, which only the post page carries.
images: skoolImagesOf(post.metadata),
label_id: post.labelId ?? null,
created_at: post.createdAt ?? null,
// THE COUNTS THE POST ALREADY CARRIES. The detail read dropped them, so a
// face over one post drew a post with no likes and no comments beside a
// feed row that had both.
comment_count: post.metadata?.comments ?? 0,
like_count: post.metadata?.upvotes ?? 0,
permalink: `${SITE_ORIGIN}/${group}/${post.name}`,
comments: comments.comments,
pinned_comments: comments.pinned_comments,
comments_cursor: comments.last,
};
}
/** THE COMMENTS API SPEAKS SNAKE_CASE ⟨MEASURED 2026-09-09⟩, and this reader
* asked it for camelCase. `first_name`, `last_name`, `created_at`,
* `picture_bubble` — every one of them answered undefined, so EVERY comment
* this hand has ever returned carried `author: ""` and `created_at: null`.
* It was invisible while the answer was prose; the moment a face drew it, the
* thread was a wall of unattributed text. Both spellings are read now, because
* the site's page road (`__NEXT_DATA__`) really does answer camelCase while
* api2 answers snake_case — one reader, two dialects, named. */
function person(user: any): string | null {
if (!user) return null;
const first = user.first_name ?? user.firstName ?? "";
const last = user.last_name ?? user.lastName ?? "";
const full = `${first} ${last}`.trim();
return full !== "" ? full : (user.name ?? null);
}
/** THE PICTURES A SKOOL POST CARRIES ⟨measured on www.skool.com 2026-09-09⟩.
*
* Skool puts a post's photographs in TWO places and the two roads carry
* different amounts, which is why this takes the metadata rather than the
* read:
*
* · THE FEED (`postTrees[].post.metadata`) carries `imagePreview` — ONE full
* size URL — plus `imagePreviewSmall`. That is all a feed page holds; there
* is no alt text anywhere in it.
* · THE POST PAGE (`postTree.post.metadata`) adds `attachmentsData`, a JSON
* STRING holding every attachment with `read_url`, `content_type` and the
* uploader's own `file_name`. So the one-post read draws the WHOLE set and
* the feed row draws the first picture — which is exactly what Skool's own
* feed does.
*
* `attachments` is NOT a road: it is a comma-joined list of attachment IDS,
* and a reader that mistook it for a URL would hand the face a broken image.
* A non-image attachment is dropped for the same reason — `Media` draws an
* `<img>`, and a PDF in that slot is a broken tile, not a document.
*
* Nothing found answers `undefined`, never `[]`: the key stays off the row and
* the face draws nothing rather than an empty frame. */
export function skoolImagesOf(metadata: any): { url: string; alt?: string }[] | undefined {
const meta = metadata || {};
const found: { url: string; alt?: string }[] = [];
const raw = typeof meta.attachmentsData === "string" ? meta.attachmentsData : "";
if (raw !== "") {
let rows: any[] = [];
try { rows = JSON.parse(raw); } catch { rows = []; }
for (const row of Array.isArray(rows) ? rows : []) {
const data = row?.metadata || {};
const type = typeof data.content_type === "string" ? data.content_type : "";
if (!type.startsWith("image/")) continue;
const url = [data.read_url, data.image_md_url, data.src_read_url]
.find((word: unknown) => typeof word === "string" && word.startsWith("http"));
if (typeof url !== "string") continue;
const alt = typeof data.file_name === "string" && data.file_name.trim() !== "" ? data.file_name : undefined;
found.push(alt === undefined ? { url } : { url, alt });
}
}
if (found.length > 0) return found;
const preview = meta.imagePreview ?? meta.image_preview;
if (typeof preview === "string" && preview.startsWith("http")) return [{ url: preview }];
return undefined;
}
function avatar(user: any): string | null {
const metadata = user?.metadata || {};
return metadata.picture_bubble ?? metadata.pictureBubble ?? metadata.pictureProfile ?? metadata.picture ?? null;
}
function compactCommentTree(child: any, depth = 0): any {
return {
id: child.post?.id,
content: child.post?.metadata?.content ?? null,
author: person(child.post?.user),
author_id: child.post?.user?.id ?? null,
author_avatar: avatar(child.post?.user),
created_at: child.post?.created_at ?? child.post?.createdAt ?? null,
like_count: child.post?.metadata?.upvotes ?? 0,
reply_count: child.children?.length || 0,
// ONE LEVEL DEEP, WHICH IS WHAT SKOOL DRAWS ⟨2026-09-09⟩. The comment
// thread face declares `replies` one level under their parent and the read
// answered a COUNT only, so every reply on the owner's own posts was
// invisible to any face over this hand. Depth is capped here rather than in
// the caller because the API's tree has no depth limit of its own.
replies: depth === 0 ? (child.children || []).map((grand: any) => compactCommentTree(grand, depth + 1)) : [],
};
}
async function commentsById(postId: string, groupId: string, limit = SKOOL_COMMENTS_PAGE) {
if (!groupId) throw new Error("Skool group id missing from post page");
const answer: any = await requestJson(`/posts/${encodeURIComponent(postId)}/comments`, {
query: { "group-id": groupId, limit, pinned: true },
});
return {
comments: (answer.post_tree?.children || []).map(compactCommentTree),
pinned_comments: (answer.pinned_post_tree?.children || []).map(compactCommentTree),
first: answer.first ?? null,
last: answer.last ?? null,
};
}
export async function listComments(slug: string, group = DEFAULT_GROUP, limit = SKOOL_COMMENTS_PAGE) {
if (!slug) throw new Error("post slug is required");
const props = await pageProps(group, `/${encodeURIComponent(slug)}`);
if (!props.postTree?.post?.id) throw new Error(`Skool post not found: ${slug}`);
return {
post_id: props.postTree.post.id,
group,
// WHICH POST, IN ITS OWN WORDS. The comment thread face heads itself with
// the post's title and the community's name; this read knew both and said
// neither, so the thread drew over an unnamed post.
post_title: props.postTree.post.metadata?.title ?? null,
community: communityWords(props.currentGroup, group),
...(await commentsById(props.postTree.post.id, props.currentGroup?.id, limit)),
};
}
export async function listMembers(group = DEFAULT_GROUP, page = 1) {
const props = await pageProps(group, `/-/members${page > 1 ? `?p=${page}` : ""}`);
return {
group,
page: props.page ?? page,
total: props.total ?? null,
total_pages: props.totalPages ?? null,
items_per_page: props.itemsPerPage ?? null,
members: (props.users || []).map(compactUser),
};
}
export async function listModules(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/classroom");
return (props.allCourses || []).map((course: any) => ({
id: course.id,
name: course.name,
title: course.metadata?.title ?? null,
description: course.metadata?.desc ?? null,
cover_image: course.metadata?.coverImage ?? course.metadata?.cover_image ?? null,
unit_type: course.unitType,
state: course.state,
public: course.public,
created_at: course.createdAt,
}));
}
export async function listLeaderboards(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/-/leaderboards");
return {
group,
all_time: props.allTime ?? null,
past_7_days: props.past7Days ?? null,
past_30_days: props.past30Days ?? null,
self: { all_time: props.selfAllTime ?? null, past_7_days: props.self7Days ?? null, past_30_days: props.self30Days ?? null },
levels: props.groupLevels || [],
};
}
export async function listCalendar(group = DEFAULT_GROUP) {
const props = await pageProps(group, "/calendar");
return { group, events: props.events || [], total: props.events?.length ?? props.renderData?.numCalendarEvents ?? 0 };
}
export async function listChats(limit = 30) {
const channels: any[] = [];
while (channels.length < limit) {
const pageSize = Math.min(30, limit - channels.length);
const answer: any = await requestJson("/self/chat-channels", {
query: { offset: channels.length, limit: pageSize, last: "", "unread-only": false },
});
const page = answer.channels || [];
channels.push(...page);
if (page.length < 30) break;
}
return { channels: channels.slice(0, limit) };
}
export async function listSkoolMessages(channelId: string, limit = SKOOL_CHAT_PAGE) {
if (!channelId) throw new Error("channel_id is required");
const channel = (await listChats(SKOOL_CHATS_CEILING)).channels.find((item: any) => item.id === channelId);
if (!channel?.last_message_id) throw new Error(`Chat channel not found or empty: ${channelId}`);
const answer: any = await requestJson(`/channels/${encodeURIComponent(channelId)}/messages`, {
query: { before: limit, after: SKOOL_CHAT_PAGE, msg: channel.last_message_id },
});
return {
messages: answer.messages || [],
has_more_before: answer.has_more_before ?? false,
has_more_after: answer.has_more_after ?? false,
channel: answer.channel ?? null,
};
}
export async function listQuestions(group = DEFAULT_GROUP, page = 1) {
const feed = await listPosts(group, page);
const rows = feed.posts
.filter((post: any) => post.comment_count === 0)
.map((post: any) => ({ ...post, is_question: /\?/.test(`${post.title || ""} ${String(post.content || "").slice(0, 400)}`) }));
return {
group,
page: feed.page,
scanned: feed.posts.length,
unanswered_count: rows.length,
question_count: rows.filter((row: any) => row.is_question).length,
rows,
};
}
export async function brief(group = DEFAULT_GROUP) {
const [groupInfo, admins, levels, courses, queue] = await Promise.all([
getGroup(group), listAdmins(group), listLevels(group), listCourses(group), listQuestions(group, 1),
]);
return {
ok: groupInfo.verified_scope,
group: groupInfo,
admins,
levels,
classroom: { course_count: courses.length, courses },
queue,
read_at: new Date().toISOString(),
};
}
export async function stagePost(title: string, body: string, category: string | undefined, group = DEFAULT_GROUP) {
assertGroup(group);
return stageHandOperation({
skill: "snappy-skool",
verb: "post",
argv: [title, body, ...(category ? [category] : []), "--group", group],
fields: { title, body, category: category ?? null, group },
target: group,
facet: "content",
action_label: `Post to ${group} on Skool`,
risk: "medium",
});
}
export async function stageComment(post: string, body: string, group = DEFAULT_GROUP) {
assertGroup(group);
return stageHandOperation({
skill: "snappy-skool",
verb: "comment",
argv: [post, body, "--group", group],
fields: { post, body, group },
target: post,
facet: "content",
action_label: `Comment on ${post} in ${group}`,
risk: "medium",
});
}
export async function stageDm(channelId: string, body: string) {
return stageHandOperation({
skill: "snappy-skool",
verb: "dm",
argv: [channelId, body],
fields: { channel_id: channelId, body },
target: channelId,
facet: "chat-message",
action_label: "Send Skool DM",
risk: "medium",
});
}
async function securityNonce(group: string): Promise<string | undefined> {
const props = await pageProps(group);
return typeof props.securityNonce === "string" && props.securityNonce ? props.securityNonce : undefined;
}
async function resolveLabel(category: string | undefined, group: string): Promise<string | undefined> {
if (!category) return undefined;
const labels: any[] = await listLabels(group);
const needle = category.trim().toLowerCase();
const found = labels.find((label) =>
String(label.id).toLowerCase() === needle ||
String(label.name || label.metadata?.name || label.metadata?.displayName || label.metadata?.display_name || "").trim().toLowerCase() === needle);
if (!found) throw new Error(`No exact Skool category match for: ${category}`);
return found.id;
}
async function executePost(title: string, body: string, category: string | undefined, group: string) {
const [groupInfo, label, nonce] = await Promise.all([getGroup(group), resolveLabel(category, group), securityNonce(group)]);
const metadata: JsonObject = { title, content: body };
if (label) metadata.labels = label;
return requestJson("/posts", {
method: "POST",
query: { follow: true },
nonce,
body: { post_type: "generic", group_id: groupInfo.id, metadata },
});
}
async function executeComment(postSlug: string, body: string, group: string) {
const [post, groupInfo, nonce] = await Promise.all([getPostDetail(postSlug, group), getGroup(group), securityNonce(group)]);
if (!post?.id) throw new Error(`Skool post not found: ${postSlug}`);
return requestJson("/posts", {
method: "POST",
query: { follow: false },
nonce,
body: { post_type: "comment", group_id: groupInfo.id, root_id: post.id, parent_id: post.id, metadata: { content: body } },
});
}
async function executeDm(channelId: string, body: string) {
const props = await pageProps(DEFAULT_GROUP);
const nonce = typeof props.securityNonce === "string" ? props.securityNonce : undefined;
return requestJson(`/channels/${encodeURIComponent(channelId)}/messages`, {
method: "POST",
query: { ct: "wdc" },
nonce,
body: { content: body },
});
}
/* ── THE FACE THIS READ TAKES ────────────────────────────────────────────────
*
* MEASURED 2026-09-09 02:0x, through the MCP: `posts --json` printed the
* HAND'S OWN words -- {group, page, total, posts:[{id, slug, title, content,
* author, author_id, created_at, comment_count, like_count, pinned,
* permalink}]} -- and the Skool feed face declares {communityName, memberCount,
* posts:[{title, excerpt, authorName, labelName, postedAt, likesCount,
* commentsCount, pinned}]}. Two vocabularies for one set of posts, so the real
* feed drew with real titles and BLANK authors, blank excerpts, blank counts
* and the word "Community" where the owner's own group name belongs.
*
* SO `--json` PRINTS THE FACE'S OBJECT, not the hand's. One representation,
* and it is the face's, because the face is what a person sees. The verbs'
* ordinary (non-`--json`) answers are untouched -- they are what an agent
* reads when it wants slugs and ids to go on with.
*
* AND IT NAMES ITS OWN KIND. The runner derives a face from the hand's family
* and the verb's word, and that derivation cannot reach `skool-comments` --
* "comments" folds onto `list`, which is the FEED. A hand that names its kind
* outranks the derivation (snappy-runner/src/face.ts, rule 1), so every answer
* here says which face it is and nothing is guessed over it. The extra key is
* stripped by the face's own zod props, so the same object draws unchanged.
*/
/** The two lines a feed row shows. Skool bodies carry markdown links and hard
* wraps; the face has room for a sentence or two, so the words are flattened
* and cut at a word boundary rather than mid-syllable. Never invented: a post
* with no body has no excerpt. */
/** SKOOL'S OWN MARKUP, FLATTENED TO WHAT SKOOL DRAWS ⟨2026-09-09⟩. A body comes
* back carrying `[@Robert Boulos](obj://user/8d20…)` for a mention and
* `[ol:1][li]` for a list; a face that printed those verbatim showed the
* reader an internal address where Skool shows a name. The WORDS are kept and
* the machinery is dropped -- nothing is invented and nothing is summarised. */
export function flattenSkoolMarkup(content: string): string {
return content
.replace(/\[([^\]]+)\]\((?:obj:\/\/|https?:\/\/)[^)]*\)/gu, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/\[(?:ol|ul):[^\]]*\]/gu, "")
.replace(/\[li\]/gu, "\n")
.replace(/\\([()[\]])/gu, "$1");
}
export function excerptOf(content: string | null | undefined, limit = 220): string | null {
if (typeof content !== "string" || content.trim() === "") return null;
const flat = flattenSkoolMarkup(content).replace(/\s+/gu, " ").trim();
if (flat.length <= limit) return flat;
const cut = flat.slice(0, limit);
const space = cut.lastIndexOf(" ");
return `${(space > limit * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The category's WORDS from its id, using the group's own label list. A label
* the list does not name answers null -- an id in a pill is not a category. */
export function labelWordsFor(labelId: string | null | undefined, labels: any[]): string | null {
if (!labelId) return null;
const found = (labels || []).find((label: any) => label?.id === labelId);
return found?.metadata?.display_name ?? found?.metadata?.displayName ?? found?.name ?? null;
}
/** `posts` → the `skool-feed` face. */
export function skoolFeedFace(answer: any, labels: any[] = []): Record<string, unknown> {
return {
kind: "skool-feed",
posts: (answer?.posts || []).map((post: any) => ({
// THE ROW CARRIES THE WORD THE NEXT VERB TAKES ⟨2026-09-09⟩. `post-detail`
// and `comments` both take a SLUG, and this mapper dropped it — so a feed
// face could be drawn and not one of its rows could be opened. A list
// whose rows are dead ends is not a list of anything.
slug: post.slug ?? null,
title: post.title ?? post.slug ?? "(untitled post)",
excerpt: excerptOf(post.content),
authorName: post.author || null,
authorAvatarUrl: post.author_avatar || null,
images: post.images ?? undefined,
labelName: labelWordsFor(post.label_id, labels),
postedAt: post.created_at ?? null,
likesCount: typeof post.like_count === "number" ? post.like_count : null,
commentsCount: typeof post.comment_count === "number" ? post.comment_count : null,
pinned: post.pinned === true,
})),
communityName: answer?.community?.name ?? null,
memberCount: answer?.community?.members ?? null,
// HOW MANY POSTS THE COMMUNITY HOLDS ⟨lane faces-families, 2026-09-09⟩. The
// read has printed `total` at the top level since it was written and this
// fold dropped it, so the face could say "3 posts" over a community with
// 87 and nothing could tell the two apart ⟨the owner, 01:5x: "20, not
// three"⟩.
total: typeof answer?.total === "number" ? answer.total : null,
};
}
/** `post-detail` → the `skool-post` face. */
export function skoolPostFace(detail: any, labels: any[] = []): Record<string, unknown> {
return {
kind: "skool-post",
title: detail?.title ?? detail?.slug ?? "(untitled post)",
body: typeof detail?.content === "string" ? flattenSkoolMarkup(detail.content) : "",
communityName: detail?.community?.name ?? null,
authorName: detail?.author || null,
// THE AUTHOR'S FACE, WHICH THIS FOLD USED TO COMPUTE AND THROW AWAY. The
// read has fetched `author_avatar` on every post since the feed fold was
// written; the one-post face simply never passed it on, so the owner's own
// post drew an empty grey disc over his own name ⟨2026-09-09 13:4x⟩.
authorAvatarUrl: detail?.author_avatar || null,
images: detail?.images ?? undefined,
labelName: labelWordsFor(detail?.label_id, labels),
postedAt: detail?.created_at ?? null,
likesCount: typeof detail?.like_count === "number" ? detail.like_count : null,
commentsCount: typeof detail?.comment_count === "number" ? detail.comment_count : null,
};
}
/** `comments` (and its `thread` spelling) → the `skool-comments` face. Pinned
* comments lead, because that is the order Skool draws them in. */
export function skoolCommentsFace(answer: any): Record<string, unknown> {
const row = (comment: any) => ({
author: comment?.author || "Unknown member",
text: typeof comment?.content === "string" ? flattenSkoolMarkup(comment.content) : "",
likes: typeof comment?.like_count === "number" ? comment.like_count : null,
postedAt: comment?.created_at ?? null,
avatarUrl: comment?.author_avatar || null,
replies: (comment?.replies || []).map((reply: any) => ({
author: reply?.author || "Unknown member",
text: typeof reply?.content === "string" ? flattenSkoolMarkup(reply.content) : "",
likes: typeof reply?.like_count === "number" ? reply.like_count : null,
postedAt: reply?.created_at ?? null,
avatarUrl: reply?.author_avatar || null,
})),
});
return {
kind: "skool-comments",
comments: [...(answer?.pinned_comments || []), ...(answer?.comments || [])].map(row),
postTitle: answer?.post_title ?? null,
communityName: answer?.community?.name ?? null,
};
}
/** THE COMMENT IN THE CONVERSATION IT JOINS ⟨the owner's shape law, 2026-09-09
* 01:5x: "comments … the context; this is critical"⟩.
*
* `thread` is the SAME rows `skoolCommentsFace` prints, replies and all.
*
* AND THE DRAFT NOW HAS A FACE ⟨lane faces-families, 2026-09-09⟩:
* `skool-comment` (`SkoolCommentComposer`), Skool's own comment box. Until it
* existed this printed `kind: null` and named the gap in `faceGap` rather than
* borrowing a neighbouring kind — honest, and what reached a person was the
* conversation and then nothing where the half they were deciding should have
* been. The kind is the same one whether or not there are rows: a comment IS
* the same object either way, and the thread band above it is what says which
* of the two this is ⟨`widget-entry.tsx` ThreadBand⟩. */
export function skoolCommentDecisionFace(input: {
thread: Record<string, unknown> | null; post: string; body: string; group: string;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
const rows = (input.thread?.comments ?? []) as Record<string, unknown>[];
return decisionInContext({
decisionKind: "skool-comment",
composeKind: "skool-comment",
threadKind: "skool-comments",
thread: rows,
// `body` beside `text` ⟨doors-everywhere⟩: `text` is the word the composer
// face will draw when it exists, `body` is the word `comment <post> <body>`
// takes, and the press is built from the contract's.
draft: { author: "you", text: input.body, post: input.post, community: input.group, body: input.body },
act: input.act,
doors: standingDoors(`posts the comment on ${input.post} in ${input.group} now`, "Comment"),
});
}
/** A NEW POST HAS NO CONVERSATION, and the kind says so: `skool-post` draws the
* post itself, which is exactly what is being decided. */
export function skoolPostDecisionFace(input: {
title: string; body: string; category?: string | null; group: string;
/** THE ACT A PRESS RUNS ⟨lane doors-everywhere, 2026-09-09⟩: this hand's own
* contract verb and `HAND_CONTRACT.verbs[verb].args` verbatim. Without it the
* door was a button nothing could build a press for. */
act: { verb: string; args: readonly string[]; values?: Record<string, unknown> };
}): DecisionInContext {
return decisionInContext({
decisionKind: "skool-post",
composeKind: "skool-post",
threadKind: "skool-comments",
thread: [],
draft: {
title: input.title, body: input.body,
communityName: input.group, labelName: input.category ?? null,
...(input.category ? { category: input.category } : {}),
},
act: input.act,
doors: standingDoors(`publishes to ${input.group} on Skool now`, "Post"),
});
}
/** THE ONE PLACE a verb's answer becomes its face. Returns null for a read no
* Skool face draws -- `levels`, `links`, `health` -- and that answer prints as
* it always did, because a face nobody built is not a face to fake. */
async function faceForVerb(command: string, answer: any, group: string): Promise<Record<string, unknown> | null> {
if (command === "posts") return skoolFeedFace(answer, await labelsOrNone(group));
if (command === "post-detail") return answer === null ? null : skoolPostFace(answer, await labelsOrNone(group));
if (command === "comments" || command === "thread") return skoolCommentsFace(answer);
return null;
}
/** The label words cost one request; a read that cannot get them draws the
* posts WITHOUT category pills rather than failing the whole answer. */
async function labelsOrNone(group: string): Promise<any[]> {
try { return await listLabels(group); } catch { return []; }
}
/** A FLAG'S VALUE IS A WORD TOO ⟨MEASURED 2026-09-09⟩. The old filter dropped
* `--json` but not what follows it, and the daemon's own caller
* (`hand-run.ts` `argvFromFields`) spells a boolean flag as a PAIR:
* `argv.push(flag, String(value))`. So the MCP road sent `posts --json true`
* and the bare `true` became the PAGE -- `positiveInt("true", 1)` throws, and
* the read failed on the one road that matters. `--group` keeps its own slug
* rule; every other `--`-word takes a trailing `true`/`false` with it. */
/** ITS NAME IS THIS HAND'S OWN ⟨lane CONTRACTS PLATFORM, 2026-09-09⟩. Two hands
* exported one `positionalWords` and skill-check's X1 caught the collision; the
* fix taken then was to drop `export` from both, which left gmail's and skool's
* face.test.ts importing a name neither file published — two test FILES that
* could not load, and so could not fail out loud either. A hand-specific name
* is the fix that keeps both: the export is back, and no two hands publish the
* same word. */
export function skoolPositionalWords(raw: readonly string[], groupAt: number): string[] {
return raw.filter((word, index) =>
!word.startsWith("--")
&& !(groupAt >= 0 && index === groupAt + 1)
&& !((word === "true" || word === "false") && (raw[index - 1] ?? "").startsWith("--") && raw[index - 1] !== "--group"));
}
function positiveInt(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const number = Number.parseInt(value, 10);
if (!Number.isFinite(number) || number < 1) throw new Error(`Expected a positive integer, got: ${value}`);
return number;
}
function humanSummary(command: string, data: any): string {
switch (command) {
case "health": return `Skool cookie ${data.ok ? "live" : "not live"}; api2=${data.api2.live}; site=${data.site.live}`;
case "me": return `Authenticated as ${data.account.first_name || data.account.handle} (${data.account.handle})`;
case "group": return `${data.display_name || data.slug}: ${data.total_members ?? "?"} members, ${data.total_admins ?? "?"} admins`;
case "admins": case "levels": case "courses": case "labels": case "links": case "modules": return `${data.length} ${command}`;
case "course": return `1 course`;
case "posts": return `${data.posts.length} posts on page ${data.page}; ${data.total ?? "?"} total`;
case "post-detail": return data ? `1 post; ${data.comments.length} comments` : "0 posts";
case "comments": case "thread": return `${data.comments.length} comments; ${data.pinned_comments.length} pinned`;
case "members": return `${data.members.length} members on page ${data.page}; ${data.total ?? "?"} total`;
case "leaderboards": return `${(data.all_time?.users?.length || 0) + (data.past_7_days?.users?.length || 0) + (data.past_30_days?.users?.length || 0)} leaderboard rows across 3 periods`;
case "calendar": return `${data.events.length} calendar events`;
case "chats": return `${data.channels.length} chats`;
case "messages": return `${data.messages.length} messages`;
case "questions": return `${data.unanswered_count} unanswered posts; ${data.question_count} questions`;
case "brief": return `${data.group.total_members ?? "?"} members; ${data.admins.length} admins; ${data.classroom.course_count} courses; ${data.queue.unanswered_count} unanswered`;
case "post": case "comment": case "dm": return data.staged ? `Staged (${data.control_id})` : `Not staged: ${JSON.stringify(data.answer)}`;
default: return typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
}
function usage(): string {
return [
"Usage: npx tsx api.ts <verb> [args] [--group slug] [--json]",
"Reads: health me group admins levels courses course labels links posts post-detail comments (alias thread) members modules leaderboards calendar chats messages questions brief",
"Staged writes: post <title> <body> [category] | comment <post-slug> <body> | dm <channel-id> <body>",
].join("\n");
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const command = process.argv[2];
if (command === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
return;
}
// ⟨R17, 2026-09-09⟩ THE COUNT LEAVES ARGV FIRST, THROUGH THE ONE PARSE.
// `skoolPositionalWords` drops `--`-words but KEEPS the word behind one, so
// a `--limit 10` left in place would land 10 in the first positional — the
// post slug, the channel id, the page number. `takeLimit`
// (snappy-settings/read-limit.ts) removes the flag and its number together;
// every index below is computed on what it left. Each arm then reads the
// count back off the RAW words under ITS OWN ceiling, so a count outside a
// verb's bound is refused BY NAME rather than clamped in silence.
const rawWords = process.argv.slice(3);
const raw = takeLimit(rawWords, { maximum: SKOOL_CHATS_CEILING }).rest;
const json = raw.includes("--json");
const now = raw.includes("--now");
const groupAt = raw.indexOf("--group");
const group = groupAt >= 0 ? raw[groupAt + 1] : DEFAULT_GROUP;
if (groupAt >= 0 && !group) throw new Error("--group requires a slug");
const positional = skoolPositionalWords(raw, groupAt);
let answer: any;
switch (command) {
case "health": answer = await health(group); break;
case "me": answer = await me(group); break;
case "group": answer = await getGroup(group); break;
case "admins": answer = await listAdmins(group); break;
case "levels": answer = await listLevels(group); break;
case "courses": answer = await listCourses(group); break;
case "course": answer = await getCourse(positional[0]); break;
case "labels": answer = await listLabels(group); break;
case "links": answer = await listLinks(group); break;
case "posts": answer = await listPosts(group, positiveInt(positional[0], 1)); break;
case "post-detail": answer = await getPostDetail(positional[0], group); break;
case "comments": case "thread": {
const bound = takeLimit(rawWords, { maximum: SKOOL_COMMENTS_PAGE, default: SKOOL_COMMENTS_PAGE });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listComments(positional[0], group, bound.limit); break;
}
case "members": answer = await listMembers(group, positiveInt(positional[0], 1)); break;
case "modules": answer = await listModules(group); break;
case "leaderboards": answer = await listLeaderboards(group); break;
case "calendar": answer = await listCalendar(group); break;
case "chats": {
const bound = takeLimit(rawWords, { maximum: SKOOL_CHATS_CEILING, default: 30 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listChats(positiveInt(positional[0], bound.limit)); break;
}
case "messages": {
const bound = takeLimit(rawWords, { maximum: SKOOL_CHAT_PAGE, default: SKOOL_CHAT_PAGE });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exitCode = 1; return; }
answer = await listSkoolMessages(positional[0], bound.limit); break;
}
case "questions": answer = await listQuestions(group, positiveInt(positional[0], 1)); break;
case "brief": answer = await brief(group); break;
case "post": {
const [title, body, category] = positional;
if (!title || !body) throw new Error("post requires <title> <body> [category]");
// A PREVIEW TOUCHES NOTHING: nothing published, nothing staged.
if (json) { console.log(JSON.stringify(skoolPostDecisionFace({ title, body, category, group,
act: { verb: "post", args: HAND_CONTRACT.verbs.post.args } }), null, 2)); return; }
answer = now ? await executePost(title, body, category, group) : await stagePost(title, body, category, group);
break;
}
case "comment": {
const [post, body] = positional;
if (!post || !body) throw new Error("comment requires <post-slug> <body>");
if (json) {
// THE CONVERSATION IS READ FIRST. A comment shown without the thread
// it joins is exactly the thing the owner will not approve.
const joined = await listComments(post, group);
console.log(JSON.stringify(skoolCommentDecisionFace({
thread: skoolCommentsFace(joined), post, body, group,
act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
}), null, 2));
return;
}
answer = now ? await executeComment(post, body, group) : await stageComment(post, body, group);
break;
}
case "dm": {
const [channelId, body] = positional;
if (!channelId || !body) throw new Error("dm requires <channel-id> <body>");
answer = now ? await executeDm(channelId, body) : await stageDm(channelId, body);
break;
}
default:
console.log(usage());
process.exitCode = command ? 1 : 0;
return;
}
// `--json` IS THE FACE'S OBJECT where a Skool face draws this shape, and the
// hand's own answer everywhere else. See "THE FACE THIS READ TAKES" above.
const face = json ? await faceForVerb(command, answer, group) : null;
console.log(json ? JSON.stringify(face ?? answer, null, 2) : humanSummary(command, answer));
if (["post", "comment", "dm"].includes(command) && !now && !answer.staged) process.exitCode = 1;
})().catch((error: any) => {
const body = error?.body ?? { ok: false, error: "hand-failed", detail: String(error?.message ?? error).slice(0, 800) };
console.log(JSON.stringify(body, null, 2));
process.exitCode = 1;
});
}
{
"providers": [
{
"name": "skool-courses",
"label": "Skool course",
"description": "Live courses from a selected Skool classroom",
"fetch": "npx tsx ~/.claude/skills/snappy-skool/api.ts modules --json",
"fields": {
"id": "id",
"label": "title",
"description": "description",
"cover_image": "cover_image",
"state": "state"
},
"verbs": []
}
]
}
{
"providers": [
{
"name": "skool-courses",
"label": "Skool course",
"description": "Live courses from a selected Skool classroom",
"fetch": "npx tsx ~/.claude/skills/snappy-skool/api.ts modules --json",
"fields": {
"id": "id",
"label": "title",
"description": "description",
"cover_image": "cover_image",
"state": "state"
},
"verbs": []
}
]
}
/**
* THE READ SPEAKS THE FACE'S LANGUAGE — proved against the face's OWN schema.
*
* MEASURED 2026-09-09 02:0x: `posts --json` printed {group, page, total,
* posts:[{title, content, author, created_at, like_count, …}]} while the Skool
* feed face declares {communityName, memberCount, posts:[{title, excerpt,
* authorName, labelName, postedAt, likesCount, commentsCount, pinned}]}. The
* real feed therefore drew real TITLES over blank authors, blank excerpts,
* blank counts and the word "Community". Every assertion below fails against
* that old answer — which is what makes it a test rather than a description.
*
* THE SCHEMA IS NEVER COPIED. `assertDrawsAs` loads the zod props the face
* itself declares (`snappy-faces/library/src/components/*.tsx`) through the one
* road at `skills/hand-face-props.ts`. A hand-written parallel of a generated
* or declared shape is the defect this collection is built against.
*
* THE DATA IS INVENTED. Quillworks and its members are fictional and the shape
* is a faithful transcription of what `listPosts`/`getPostDetail`/`listComments`
* really return; no read of the owner's own community is committed here.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { assertCarriesActArguments, assertDrawsAs, assertDrawsInContext } from "../hand-face-props.ts";
import { HAND_CONTRACT, excerptOf, flattenSkoolMarkup, labelWordsFor, skoolImagesOf, skoolPositionalWords, skoolCommentsFace, skoolCommentDecisionFace, skoolFeedFace, skoolPostDecisionFace, skoolPostFace } from "./api.ts";
const LABELS = [
{ id: "lbl-build", metadata: { display_name: "Build logs" } },
{ id: "lbl-ask", metadata: { display_name: "Questions" } },
];
const POSTS_ANSWER = {
group: "quillworks",
page: 1,
total: 87,
community: { name: "Quillworks Makers", members: 2140 },
posts: [
{
id: "p1", slug: "read-this-before-your-first-jig",
title: "Read this before you build your first jig",
content: "Three attempts, and the only one that held came from moving a single clamp point.\n\nHere is the [whole reasoning](https://example.test/jig) so you can skip the first two, with the cut list at the end and a note about which clamp face actually takes the load when the stock is wet.",
author: "Mara Quill", author_id: "u1",
author_avatar: "https://assets.example.test/f/u1/avatar",
label_id: "lbl-build",
created_at: "2026-09-04T16:20:00Z",
comment_count: 12, like_count: 47, pinned: true,
// THE PICTURE THE POST IS ABOUT ⟨measured on skool.com 2026-09-09⟩. The
// feed's `__NEXT_DATA__` carries exactly one, in `metadata.imagePreview`.
images: [{ url: "https://assets.example.test/f/g1/jig-clamp.png" }],
permalink: "https://www.skool.com/quillworks/read-this-before-your-first-jig",
},
{
id: "p2", slug: "baltic-birch-price",
title: "Anyone sourcing Baltic birch under $60 a sheet?",
content: "My usual supplier went to $78.",
author: "Milo Fenwick", author_id: "u2", author_avatar: null,
label_id: "lbl-ask",
created_at: "2026-09-02T14:12:00Z",
comment_count: 23, like_count: 9, pinned: false,
permalink: "https://www.skool.com/quillworks/baltic-birch-price",
},
],
};
test("posts draws as skool-feed with the community's own words", async () => {
const face = skoolFeedFace(POSTS_ANSWER, LABELS);
assert.equal(face.kind, "skool-feed");
const drawn = await assertDrawsAs("skool-feed", face);
// THE HEAD. "Community" over the owner's own group was the visible defect.
assert.equal(drawn.communityName, "Quillworks Makers");
assert.equal(drawn.memberCount, 2140);
// HOW MANY THE COMMUNITY HOLDS ⟨lane faces-families, 2026-09-09⟩. The read has
// always printed `total`; the fold dropped it, so a face drawn from two rows
// of an 87-post community said "2 posts" and there was no way to tell.
assert.equal(drawn.total, 87);
const rows = drawn.posts as Record<string, unknown>[];
assert.equal(rows.length, 2);
// EVERY FIELD THE OWNER SAW BLANK, filled from the platform's real values.
assert.equal(rows[0].authorName, "Mara Quill");
assert.equal(rows[0].labelName, "Build logs");
assert.equal(rows[0].postedAt, "2026-09-04T16:20:00Z");
assert.equal(rows[0].likesCount, 47);
assert.equal(rows[0].commentsCount, 12);
assert.equal(rows[0].pinned, true);
assert.equal(rows[0].authorAvatarUrl, "https://assets.example.test/f/u1/avatar");
// THE PICTURES ⟨the owner, 2026-09-09 14:0x: "the images are not shown, which
// looks really bad"⟩. Skool's feed carries one per post and the row carries it
// through; a post with none carries none, never an empty frame.
assert.deepEqual(rows[0].images, [{ url: "https://assets.example.test/f/g1/jig-clamp.png" }]);
assert.equal(rows[1].images, undefined);
// The excerpt is the body flattened and cut at a word, with the markdown
// link's WORDS kept and its address dropped.
const excerpt = String(rows[0].excerpt);
assert.ok(excerpt.startsWith("Three attempts, and the only one that held"), excerpt);
assert.ok(excerpt.includes("whole reasoning"), excerpt);
assert.ok(!excerpt.includes("https://"), excerpt);
assert.ok(excerpt.endsWith("…"), excerpt);
assert.ok(excerpt.length <= 224, `excerpt is ${excerpt.length} characters`);
// A short body is not cut at all.
assert.equal(rows[1].excerpt, "My usual supplier went to $78.");
assert.equal(rows[1].labelName, "Questions");
});
test("post-detail draws as skool-post", async () => {
const face = skoolPostFace({
id: "p1", slug: "read-this-before-your-first-jig",
community: { name: "Quillworks Makers", members: 2140 },
title: "Read this before you build your first jig",
content: "Three attempts, and the only one that held came from moving a single clamp point.",
author: "Mara Quill", author_avatar: "https://assets.example.test/f/u1/avatar",
label_id: "lbl-build",
// THE DETAIL READ CARRIES THE WHOLE SET ⟨measured⟩: `attachmentsData` is a
// JSON array with a read URL and the uploader's own file name per picture,
// where the feed carries only the one preview.
images: [
{ url: "https://assets.example.test/f/g1/jig-clamp.png", alt: "clamp point.png" },
{ url: "https://assets.example.test/f/g1/jig-cutlist.png", alt: "cut list.png" },
],
created_at: "2026-09-04T16:20:00Z", comment_count: 12, like_count: 47,
}, LABELS);
assert.equal(face.kind, "skool-post");
const drawn = await assertDrawsAs("skool-post", face);
assert.equal(drawn.title, "Read this before you build your first jig");
assert.equal(drawn.authorName, "Mara Quill");
// THE AUTHOR'S FACE. `skoolPostFace` COMPUTED this and dropped it on the
// floor — the one post face drew an empty disc over a name the read had a
// photo for ⟨the owner's screenshot, 2026-09-09 13:4x⟩.
assert.equal(drawn.authorAvatarUrl, "https://assets.example.test/f/u1/avatar");
assert.deepEqual(drawn.images, [
{ url: "https://assets.example.test/f/g1/jig-clamp.png", alt: "clamp point.png" },
{ url: "https://assets.example.test/f/g1/jig-cutlist.png", alt: "cut list.png" },
]);
assert.equal(drawn.communityName, "Quillworks Makers");
assert.equal(drawn.labelName, "Build logs");
assert.equal(drawn.likesCount, 47);
assert.equal(drawn.commentsCount, 12);
// The BODY is the post's full text, never the feed's cut excerpt.
assert.ok(String(drawn.body).endsWith("clamp point."), String(drawn.body));
});
test("comments draws as skool-comments, pinned first, replies under their parent", async () => {
const face = skoolCommentsFace({
post_id: "p1", group: "quillworks",
post_title: "Read this before you build your first jig",
community: { name: "Quillworks Makers", members: 2140 },
pinned_comments: [
{ id: "c0", content: "Cut list is in the third photo.", author: "Mara Quill", author_avatar: null, created_at: "2026-09-04T17:00:00Z", like_count: 8, reply_count: 0, replies: [] },
],
comments: [
{
id: "c1", content: "Moving the clamp inboard fixed mine too.", author: "Nadia Brandt",
author_avatar: "https://assets.example.test/f/u3/avatar", created_at: "2026-09-04T18:30:00Z",
like_count: 3, reply_count: 1,
replies: [
{ id: "c2", content: "Same, two inches was the number.", author: "Milo Fenwick", author_avatar: null, created_at: "2026-09-04T19:02:00Z", like_count: 1, reply_count: 0, replies: [] },
],
},
],
});
assert.equal(face.kind, "skool-comments");
const drawn = await assertDrawsAs("skool-comments", face);
assert.equal(drawn.postTitle, "Read this before you build your first jig");
assert.equal(drawn.communityName, "Quillworks Makers");
const comments = drawn.comments as Record<string, unknown>[];
// PINNED LEADS, which is the order Skool itself draws.
assert.equal(comments[0].author, "Mara Quill");
assert.equal(comments[0].text, "Cut list is in the third photo.");
assert.equal(comments[0].likes, 8);
assert.equal(comments[1].author, "Nadia Brandt");
// THE REPLY. The read used to answer a COUNT only, so no reply could be drawn.
const replies = comments[1].replies as Record<string, unknown>[];
assert.equal(replies.length, 1);
assert.equal(replies[0].author, "Milo Fenwick");
assert.equal(replies[0].text, "Same, two inches was the number.");
});
test("a label the group does not name is null, never its id", () => {
assert.equal(labelWordsFor("lbl-gone", LABELS), null);
assert.equal(labelWordsFor(null, LABELS), null);
});
test("a post with no body has no excerpt, never an invented one", () => {
assert.equal(excerptOf(null), null);
assert.equal(excerptOf(" "), null);
});
test("Skool's own markup is flattened to the words Skool draws", () => {
// MEASURED on a real thread: a mention arrives as an internal object address
// and drew as one on the face until this fold existed.
assert.equal(
flattenSkoolMarkup("[@Mara Quill](obj://user/8d206d1ec98646fca7840198a7c0b5dd) said the same."),
"@Mara Quill said the same.",
);
assert.equal(flattenSkoolMarkup("Start [here](https://example.test/x)."), "Start here.");
assert.equal(flattenSkoolMarkup("[ol:1][li]First[li]Second"), "\nFirst\nSecond");
assert.equal(flattenSkoolMarkup("cost \\(before tax\\)"), "cost (before tax)");
});
test("a flag and the word behind it never become a positional, and --group keeps its slug", () => {
// MEASURED: `posts --json true` filed `true` as the PAGE, and
// positiveInt("true", 1) throws — the read failed on the MCP road only.
assert.deepEqual(skoolPositionalWords(["--json", "true"], -1), []);
assert.deepEqual(skoolPositionalWords(["2", "--json", "true"], -1), ["2"]);
assert.deepEqual(skoolPositionalWords(["--group", "quillworks", "--json", "true"], 0), []);
// A group whose slug really is "true" is still that group's slug, never a
// positional and never dropped as a flag's value.
assert.deepEqual(skoolPositionalWords(["--group", "true", "2"], 0), ["2"]);
});
const COMMENTS_ANSWER = {
post_id: "p1", group: "quillworks",
post_title: "Read this before you build your first jig",
community: { name: "Quillworks Makers", members: 2140 },
pinned_comments: [
{ id: "c0", content: "Cut list is in the third photo.", author: "Mara Quill", author_avatar: null, created_at: "2026-09-04T17:00:00Z", like_count: 8, replies: [] },
],
comments: [
{ id: "c1", content: "Moving the clamp inboard fixed mine too.", author: "Nadia Brandt", author_avatar: null, created_at: "2026-09-04T18:30:00Z", like_count: 3,
replies: [{ id: "c2", content: "Same, two inches was the number.", author: "Milo Fenwick", author_avatar: null, created_at: "2026-09-04T19:02:00Z", like_count: 1, replies: [] }] },
],
};
const asCommentsFace = (rows: Record<string, unknown>[]) => ({
comments: rows, postTitle: "Read this before you build your first jig", communityName: "Quillworks Makers",
});
test("a feed row carries the slug the next verbs take", () => {
// MEASURED 2026-09-09: this mapper dropped `slug`, and BOTH `post-detail` and
// `comments` take one — so a feed face could be drawn and not one of its rows
// could be opened.
const face = skoolFeedFace(POSTS_ANSWER, LABELS);
const posts = face.posts as Record<string, unknown>[];
assert.equal(posts[0].slug, "read-this-before-your-first-jig");
assert.equal(posts[1].slug, "baltic-birch-price");
});
test("a comment arrives inside the conversation it joins, and draws as Skool's comment box", async () => {
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
thread: skoolCommentsFace(COMMENTS_ANSWER),
post: "read-this-before-your-first-jig",
body: "Two inches inboard here as well — worth putting in the cut list.",
group: "quillworks",
});
// THE COMPOSER EXISTS NOW ⟨lane faces-families, 2026-09-09⟩. This asserted
// `kind === null` and a `faceGap` naming the missing face for two days; the
// face landed, so the assertion is the opposite one — the draft draws, and
// `assertDrawsInContext` puts it through `SkoolCommentComposer`'s OWN zod
// schema rather than a shape typed out beside it.
assert.equal(face.kind, "skool-comment");
assert.equal(face.faceGap, undefined);
assert.equal(face.threadKind, "skool-comments");
const { thread } = await assertDrawsInContext(face, asCommentsFace);
// THE CONTEXT IS THE POINT: pinned first, replies under their parent, exactly
// as `comments --json` prints.
assert.equal(thread.length, 2);
assert.equal(thread[0].author, "Mara Quill");
assert.equal(thread[1].author, "Nadia Brandt");
assert.equal((thread[1].replies as Record<string, unknown>[])[0].author, "Milo Fenwick");
assert.equal(face.draft.text, "Two inches inboard here as well — worth putting in the cut list.");
assert.deepEqual(face.doors.map((d) => d.label), ["Comment", "Later"]);
});
test("a comment with no conversation yet still draws as the composer", async () => {
// A FIRST COMMENT IS THE SAME OBJECT as a reply — the thread band above it is
// what says which of the two it is — so the compose kind is the decision kind
// and neither road can print a null.
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
thread: null, post: "jig-cut-list", body: "The second clamp is the one to change.", group: "quillworks" });
assert.equal(face.kind, "skool-comment");
assert.deepEqual(face.thread, []);
const { draft } = await assertDrawsInContext(face, asCommentsFace);
assert.equal(draft.text, "The second clamp is the one to change.");
assert.equal(draft.post, "jig-cut-list");
assert.equal(draft.community, "quillworks");
});
test("a new post draws as the post itself, with no conversation to show", async () => {
const face = skoolPostDecisionFace({ act: { verb: "post", args: HAND_CONTRACT.verbs.post.args }, title: "Jig cut list", body: "Three clamps, one of them wrong.", category: "Build logs", group: "quillworks" });
assert.equal(face.kind, "skool-post");
assert.deepEqual(face.thread, []);
assert.equal(face.threadKind, null);
const { draft } = await assertDrawsInContext(face, asCommentsFace);
assert.equal(draft.title, "Jig cut list");
assert.equal(draft.body, "Three clamps, one of them wrong.");
assert.equal(draft.labelName, "Build logs");
assert.equal(draft.communityName, "quillworks");
});
test("the preview carries every argument its own door's press would run", () => {
// RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩: the draft spelled the words `text` and the verb takes `body`, so a runner holding
// this preview and a primary door could not build the press at all. The
// check is the collection's shared one, read from the composite's own `act`
// against this hand's contract — never a list typed out beside it.
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args }, thread: null, post: "jig-cut-list", body: "The second clamp is the one to change.", group: "quillworks" });
const act = assertCarriesActArguments(HAND_CONTRACT, face);
assert.equal(act.arguments.post, "jig-cut-list");
assert.equal(act.arguments.body, "The second clamp is the one to change.");
});
test("a post's pictures are read from the fields Skool really carries", () => {
// MEASURED on www.skool.com/snappy 2026-09-09, both roads, verbatim shapes.
//
// THE FEED (`postTrees[].post.metadata`) carries ONE picture and no alt:
// `imagePreview` is the full-size read URL and `imagePreviewSmall` a
// thumbnail. `attachments` is present but is an ID STRING, not a URL, so a
// reader that took it would draw a broken image.
assert.deepEqual(skoolImagesOf({
attachments: "6c3e3b43afeb443694a6d9f4cf3e2f16",
imagePreview: "https://assets.skool.com/f/g/full.png",
imagePreviewSmall: "https://assets.skool.com/f/g/full-md.png",
videoIds: "",
}), [{ url: "https://assets.skool.com/f/g/full.png" }]);
// THE POST PAGE (`postTree.post.metadata`) adds `attachmentsData` — a JSON
// STRING holding every attachment with its own `read_url` and the uploader's
// own `file_name`, which is the only alt text Skool has. All of them are
// carried, in order, and the file name becomes the alt.
assert.deepEqual(skoolImagesOf({
attachments: "a1,a2",
attachmentsData: JSON.stringify([
{ id: "a1", metadata: { content_type: "image/png", file_name: "CleanShot one.png", read_url: "https://assets.skool.com/f/g/one.png", image_md_url: "https://assets.skool.com/f/g/one-md.png" } },
{ id: "a2", metadata: { content_type: "image/png", file_name: "two.png", read_url: "https://assets.skool.com/f/g/two.png" } },
]),
imagePreview: "https://assets.skool.com/f/g/one.png",
}), [
{ url: "https://assets.skool.com/f/g/one.png", alt: "CleanShot one.png" },
{ url: "https://assets.skool.com/f/g/two.png", alt: "two.png" },
]);
// A NON-IMAGE ATTACHMENT IS NOT A PICTURE. `Media` would draw a PDF as a
// broken tile, so only `image/*` rows are carried and a post whose only
// attachment is a document reports no images at all rather than one.
assert.equal(skoolImagesOf({
attachmentsData: JSON.stringify([
{ id: "a1", metadata: { content_type: "application/pdf", file_name: "spec.pdf", read_url: "https://assets.skool.com/f/g/spec.pdf" } },
]),
}), undefined);
// NOTHING IS NOT AN EMPTY ARRAY. `undefined` keeps the key off the row, and
// the face draws nothing rather than an empty frame.
assert.equal(skoolImagesOf({ videoIds: "", content: "words only" }), undefined);
assert.equal(skoolImagesOf(null), undefined);
// Malformed JSON is not a crash and not an invented picture: the preview the
// same payload carries is still honest.
assert.deepEqual(skoolImagesOf({ attachmentsData: "{not json", imagePreview: "https://assets.skool.com/f/g/one.png" }),
[{ url: "https://assets.skool.com/f/g/one.png" }]);
});
/**
* THE READ SPEAKS THE FACE'S LANGUAGE — proved against the face's OWN schema.
*
* MEASURED 2026-09-09 02:0x: `posts --json` printed {group, page, total,
* posts:[{title, content, author, created_at, like_count, …}]} while the Skool
* feed face declares {communityName, memberCount, posts:[{title, excerpt,
* authorName, labelName, postedAt, likesCount, commentsCount, pinned}]}. The
* real feed therefore drew real TITLES over blank authors, blank excerpts,
* blank counts and the word "Community". Every assertion below fails against
* that old answer — which is what makes it a test rather than a description.
*
* THE SCHEMA IS NEVER COPIED. `assertDrawsAs` loads the zod props the face
* itself declares (`snappy-faces/library/src/components/*.tsx`) through the one
* road at `skills/hand-face-props.ts`. A hand-written parallel of a generated
* or declared shape is the defect this collection is built against.
*
* THE DATA IS INVENTED. Quillworks and its members are fictional and the shape
* is a faithful transcription of what `listPosts`/`getPostDetail`/`listComments`
* really return; no read of the owner's own community is committed here.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { assertCarriesActArguments, assertDrawsAs, assertDrawsInContext } from "../hand-face-props.ts";
import { HAND_CONTRACT, excerptOf, flattenSkoolMarkup, labelWordsFor, skoolImagesOf, skoolPositionalWords, skoolCommentsFace, skoolCommentDecisionFace, skoolFeedFace, skoolPostDecisionFace, skoolPostFace } from "./api.ts";
const LABELS = [
{ id: "lbl-build", metadata: { display_name: "Build logs" } },
{ id: "lbl-ask", metadata: { display_name: "Questions" } },
];
const POSTS_ANSWER = {
group: "quillworks",
page: 1,
total: 87,
community: { name: "Quillworks Makers", members: 2140 },
posts: [
{
id: "p1", slug: "read-this-before-your-first-jig",
title: "Read this before you build your first jig",
content: "Three attempts, and the only one that held came from moving a single clamp point.\n\nHere is the [whole reasoning](https://example.test/jig) so you can skip the first two, with the cut list at the end and a note about which clamp face actually takes the load when the stock is wet.",
author: "Mara Quill", author_id: "u1",
author_avatar: "https://assets.example.test/f/u1/avatar",
label_id: "lbl-build",
created_at: "2026-09-04T16:20:00Z",
comment_count: 12, like_count: 47, pinned: true,
// THE PICTURE THE POST IS ABOUT ⟨measured on skool.com 2026-09-09⟩. The
// feed's `__NEXT_DATA__` carries exactly one, in `metadata.imagePreview`.
images: [{ url: "https://assets.example.test/f/g1/jig-clamp.png" }],
permalink: "https://www.skool.com/quillworks/read-this-before-your-first-jig",
},
{
id: "p2", slug: "baltic-birch-price",
title: "Anyone sourcing Baltic birch under $60 a sheet?",
content: "My usual supplier went to $78.",
author: "Milo Fenwick", author_id: "u2", author_avatar: null,
label_id: "lbl-ask",
created_at: "2026-09-02T14:12:00Z",
comment_count: 23, like_count: 9, pinned: false,
permalink: "https://www.skool.com/quillworks/baltic-birch-price",
},
],
};
test("posts draws as skool-feed with the community's own words", async () => {
const face = skoolFeedFace(POSTS_ANSWER, LABELS);
assert.equal(face.kind, "skool-feed");
const drawn = await assertDrawsAs("skool-feed", face);
// THE HEAD. "Community" over the owner's own group was the visible defect.
assert.equal(drawn.communityName, "Quillworks Makers");
assert.equal(drawn.memberCount, 2140);
// HOW MANY THE COMMUNITY HOLDS ⟨lane faces-families, 2026-09-09⟩. The read has
// always printed `total`; the fold dropped it, so a face drawn from two rows
// of an 87-post community said "2 posts" and there was no way to tell.
assert.equal(drawn.total, 87);
const rows = drawn.posts as Record<string, unknown>[];
assert.equal(rows.length, 2);
// EVERY FIELD THE OWNER SAW BLANK, filled from the platform's real values.
assert.equal(rows[0].authorName, "Mara Quill");
assert.equal(rows[0].labelName, "Build logs");
assert.equal(rows[0].postedAt, "2026-09-04T16:20:00Z");
assert.equal(rows[0].likesCount, 47);
assert.equal(rows[0].commentsCount, 12);
assert.equal(rows[0].pinned, true);
assert.equal(rows[0].authorAvatarUrl, "https://assets.example.test/f/u1/avatar");
// THE PICTURES ⟨the owner, 2026-09-09 14:0x: "the images are not shown, which
// looks really bad"⟩. Skool's feed carries one per post and the row carries it
// through; a post with none carries none, never an empty frame.
assert.deepEqual(rows[0].images, [{ url: "https://assets.example.test/f/g1/jig-clamp.png" }]);
assert.equal(rows[1].images, undefined);
// The excerpt is the body flattened and cut at a word, with the markdown
// link's WORDS kept and its address dropped.
const excerpt = String(rows[0].excerpt);
assert.ok(excerpt.startsWith("Three attempts, and the only one that held"), excerpt);
assert.ok(excerpt.includes("whole reasoning"), excerpt);
assert.ok(!excerpt.includes("https://"), excerpt);
assert.ok(excerpt.endsWith("…"), excerpt);
assert.ok(excerpt.length <= 224, `excerpt is ${excerpt.length} characters`);
// A short body is not cut at all.
assert.equal(rows[1].excerpt, "My usual supplier went to $78.");
assert.equal(rows[1].labelName, "Questions");
});
test("post-detail draws as skool-post", async () => {
const face = skoolPostFace({
id: "p1", slug: "read-this-before-your-first-jig",
community: { name: "Quillworks Makers", members: 2140 },
title: "Read this before you build your first jig",
content: "Three attempts, and the only one that held came from moving a single clamp point.",
author: "Mara Quill", author_avatar: "https://assets.example.test/f/u1/avatar",
label_id: "lbl-build",
// THE DETAIL READ CARRIES THE WHOLE SET ⟨measured⟩: `attachmentsData` is a
// JSON array with a read URL and the uploader's own file name per picture,
// where the feed carries only the one preview.
images: [
{ url: "https://assets.example.test/f/g1/jig-clamp.png", alt: "clamp point.png" },
{ url: "https://assets.example.test/f/g1/jig-cutlist.png", alt: "cut list.png" },
],
created_at: "2026-09-04T16:20:00Z", comment_count: 12, like_count: 47,
}, LABELS);
assert.equal(face.kind, "skool-post");
const drawn = await assertDrawsAs("skool-post", face);
assert.equal(drawn.title, "Read this before you build your first jig");
assert.equal(drawn.authorName, "Mara Quill");
// THE AUTHOR'S FACE. `skoolPostFace` COMPUTED this and dropped it on the
// floor — the one post face drew an empty disc over a name the read had a
// photo for ⟨the owner's screenshot, 2026-09-09 13:4x⟩.
assert.equal(drawn.authorAvatarUrl, "https://assets.example.test/f/u1/avatar");
assert.deepEqual(drawn.images, [
{ url: "https://assets.example.test/f/g1/jig-clamp.png", alt: "clamp point.png" },
{ url: "https://assets.example.test/f/g1/jig-cutlist.png", alt: "cut list.png" },
]);
assert.equal(drawn.communityName, "Quillworks Makers");
assert.equal(drawn.labelName, "Build logs");
assert.equal(drawn.likesCount, 47);
assert.equal(drawn.commentsCount, 12);
// The BODY is the post's full text, never the feed's cut excerpt.
assert.ok(String(drawn.body).endsWith("clamp point."), String(drawn.body));
});
test("comments draws as skool-comments, pinned first, replies under their parent", async () => {
const face = skoolCommentsFace({
post_id: "p1", group: "quillworks",
post_title: "Read this before you build your first jig",
community: { name: "Quillworks Makers", members: 2140 },
pinned_comments: [
{ id: "c0", content: "Cut list is in the third photo.", author: "Mara Quill", author_avatar: null, created_at: "2026-09-04T17:00:00Z", like_count: 8, reply_count: 0, replies: [] },
],
comments: [
{
id: "c1", content: "Moving the clamp inboard fixed mine too.", author: "Nadia Brandt",
author_avatar: "https://assets.example.test/f/u3/avatar", created_at: "2026-09-04T18:30:00Z",
like_count: 3, reply_count: 1,
replies: [
{ id: "c2", content: "Same, two inches was the number.", author: "Milo Fenwick", author_avatar: null, created_at: "2026-09-04T19:02:00Z", like_count: 1, reply_count: 0, replies: [] },
],
},
],
});
assert.equal(face.kind, "skool-comments");
const drawn = await assertDrawsAs("skool-comments", face);
assert.equal(drawn.postTitle, "Read this before you build your first jig");
assert.equal(drawn.communityName, "Quillworks Makers");
const comments = drawn.comments as Record<string, unknown>[];
// PINNED LEADS, which is the order Skool itself draws.
assert.equal(comments[0].author, "Mara Quill");
assert.equal(comments[0].text, "Cut list is in the third photo.");
assert.equal(comments[0].likes, 8);
assert.equal(comments[1].author, "Nadia Brandt");
// THE REPLY. The read used to answer a COUNT only, so no reply could be drawn.
const replies = comments[1].replies as Record<string, unknown>[];
assert.equal(replies.length, 1);
assert.equal(replies[0].author, "Milo Fenwick");
assert.equal(replies[0].text, "Same, two inches was the number.");
});
test("a label the group does not name is null, never its id", () => {
assert.equal(labelWordsFor("lbl-gone", LABELS), null);
assert.equal(labelWordsFor(null, LABELS), null);
});
test("a post with no body has no excerpt, never an invented one", () => {
assert.equal(excerptOf(null), null);
assert.equal(excerptOf(" "), null);
});
test("Skool's own markup is flattened to the words Skool draws", () => {
// MEASURED on a real thread: a mention arrives as an internal object address
// and drew as one on the face until this fold existed.
assert.equal(
flattenSkoolMarkup("[@Mara Quill](obj://user/8d206d1ec98646fca7840198a7c0b5dd) said the same."),
"@Mara Quill said the same.",
);
assert.equal(flattenSkoolMarkup("Start [here](https://example.test/x)."), "Start here.");
assert.equal(flattenSkoolMarkup("[ol:1][li]First[li]Second"), "\nFirst\nSecond");
assert.equal(flattenSkoolMarkup("cost \\(before tax\\)"), "cost (before tax)");
});
test("a flag and the word behind it never become a positional, and --group keeps its slug", () => {
// MEASURED: `posts --json true` filed `true` as the PAGE, and
// positiveInt("true", 1) throws — the read failed on the MCP road only.
assert.deepEqual(skoolPositionalWords(["--json", "true"], -1), []);
assert.deepEqual(skoolPositionalWords(["2", "--json", "true"], -1), ["2"]);
assert.deepEqual(skoolPositionalWords(["--group", "quillworks", "--json", "true"], 0), []);
// A group whose slug really is "true" is still that group's slug, never a
// positional and never dropped as a flag's value.
assert.deepEqual(skoolPositionalWords(["--group", "true", "2"], 0), ["2"]);
});
const COMMENTS_ANSWER = {
post_id: "p1", group: "quillworks",
post_title: "Read this before you build your first jig",
community: { name: "Quillworks Makers", members: 2140 },
pinned_comments: [
{ id: "c0", content: "Cut list is in the third photo.", author: "Mara Quill", author_avatar: null, created_at: "2026-09-04T17:00:00Z", like_count: 8, replies: [] },
],
comments: [
{ id: "c1", content: "Moving the clamp inboard fixed mine too.", author: "Nadia Brandt", author_avatar: null, created_at: "2026-09-04T18:30:00Z", like_count: 3,
replies: [{ id: "c2", content: "Same, two inches was the number.", author: "Milo Fenwick", author_avatar: null, created_at: "2026-09-04T19:02:00Z", like_count: 1, replies: [] }] },
],
};
const asCommentsFace = (rows: Record<string, unknown>[]) => ({
comments: rows, postTitle: "Read this before you build your first jig", communityName: "Quillworks Makers",
});
test("a feed row carries the slug the next verbs take", () => {
// MEASURED 2026-09-09: this mapper dropped `slug`, and BOTH `post-detail` and
// `comments` take one — so a feed face could be drawn and not one of its rows
// could be opened.
const face = skoolFeedFace(POSTS_ANSWER, LABELS);
const posts = face.posts as Record<string, unknown>[];
assert.equal(posts[0].slug, "read-this-before-your-first-jig");
assert.equal(posts[1].slug, "baltic-birch-price");
});
test("a comment arrives inside the conversation it joins, and draws as Skool's comment box", async () => {
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
thread: skoolCommentsFace(COMMENTS_ANSWER),
post: "read-this-before-your-first-jig",
body: "Two inches inboard here as well — worth putting in the cut list.",
group: "quillworks",
});
// THE COMPOSER EXISTS NOW ⟨lane faces-families, 2026-09-09⟩. This asserted
// `kind === null` and a `faceGap` naming the missing face for two days; the
// face landed, so the assertion is the opposite one — the draft draws, and
// `assertDrawsInContext` puts it through `SkoolCommentComposer`'s OWN zod
// schema rather than a shape typed out beside it.
assert.equal(face.kind, "skool-comment");
assert.equal(face.faceGap, undefined);
assert.equal(face.threadKind, "skool-comments");
const { thread } = await assertDrawsInContext(face, asCommentsFace);
// THE CONTEXT IS THE POINT: pinned first, replies under their parent, exactly
// as `comments --json` prints.
assert.equal(thread.length, 2);
assert.equal(thread[0].author, "Mara Quill");
assert.equal(thread[1].author, "Nadia Brandt");
assert.equal((thread[1].replies as Record<string, unknown>[])[0].author, "Milo Fenwick");
assert.equal(face.draft.text, "Two inches inboard here as well — worth putting in the cut list.");
assert.deepEqual(face.doors.map((d) => d.label), ["Comment", "Later"]);
});
test("a comment with no conversation yet still draws as the composer", async () => {
// A FIRST COMMENT IS THE SAME OBJECT as a reply — the thread band above it is
// what says which of the two it is — so the compose kind is the decision kind
// and neither road can print a null.
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args },
thread: null, post: "jig-cut-list", body: "The second clamp is the one to change.", group: "quillworks" });
assert.equal(face.kind, "skool-comment");
assert.deepEqual(face.thread, []);
const { draft } = await assertDrawsInContext(face, asCommentsFace);
assert.equal(draft.text, "The second clamp is the one to change.");
assert.equal(draft.post, "jig-cut-list");
assert.equal(draft.community, "quillworks");
});
test("a new post draws as the post itself, with no conversation to show", async () => {
const face = skoolPostDecisionFace({ act: { verb: "post", args: HAND_CONTRACT.verbs.post.args }, title: "Jig cut list", body: "Three clamps, one of them wrong.", category: "Build logs", group: "quillworks" });
assert.equal(face.kind, "skool-post");
assert.deepEqual(face.thread, []);
assert.equal(face.threadKind, null);
const { draft } = await assertDrawsInContext(face, asCommentsFace);
assert.equal(draft.title, "Jig cut list");
assert.equal(draft.body, "Three clamps, one of them wrong.");
assert.equal(draft.labelName, "Build logs");
assert.equal(draft.communityName, "quillworks");
});
test("the preview carries every argument its own door's press would run", () => {
// RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩: the draft spelled the words `text` and the verb takes `body`, so a runner holding
// this preview and a primary door could not build the press at all. The
// check is the collection's shared one, read from the composite's own `act`
// against this hand's contract — never a list typed out beside it.
const face = skoolCommentDecisionFace({ act: { verb: "comment", args: HAND_CONTRACT.verbs.comment.args }, thread: null, post: "jig-cut-list", body: "The second clamp is the one to change.", group: "quillworks" });
const act = assertCarriesActArguments(HAND_CONTRACT, face);
assert.equal(act.arguments.post, "jig-cut-list");
assert.equal(act.arguments.body, "The second clamp is the one to change.");
});
test("a post's pictures are read from the fields Skool really carries", () => {
// MEASURED on www.skool.com/snappy 2026-09-09, both roads, verbatim shapes.
//
// THE FEED (`postTrees[].post.metadata`) carries ONE picture and no alt:
// `imagePreview` is the full-size read URL and `imagePreviewSmall` a
// thumbnail. `attachments` is present but is an ID STRING, not a URL, so a
// reader that took it would draw a broken image.
assert.deepEqual(skoolImagesOf({
attachments: "6c3e3b43afeb443694a6d9f4cf3e2f16",
imagePreview: "https://assets.skool.com/f/g/full.png",
imagePreviewSmall: "https://assets.skool.com/f/g/full-md.png",
videoIds: "",
}), [{ url: "https://assets.skool.com/f/g/full.png" }]);
// THE POST PAGE (`postTree.post.metadata`) adds `attachmentsData` — a JSON
// STRING holding every attachment with its own `read_url` and the uploader's
// own `file_name`, which is the only alt text Skool has. All of them are
// carried, in order, and the file name becomes the alt.
assert.deepEqual(skoolImagesOf({
attachments: "a1,a2",
attachmentsData: JSON.stringify([
{ id: "a1", metadata: { content_type: "image/png", file_name: "CleanShot one.png", read_url: "https://assets.skool.com/f/g/one.png", image_md_url: "https://assets.skool.com/f/g/one-md.png" } },
{ id: "a2", metadata: { content_type: "image/png", file_name: "two.png", read_url: "https://assets.skool.com/f/g/two.png" } },
]),
imagePreview: "https://assets.skool.com/f/g/one.png",
}), [
{ url: "https://assets.skool.com/f/g/one.png", alt: "CleanShot one.png" },
{ url: "https://assets.skool.com/f/g/two.png", alt: "two.png" },
]);
// A NON-IMAGE ATTACHMENT IS NOT A PICTURE. `Media` would draw a PDF as a
// broken tile, so only `image/*` rows are carried and a post whose only
// attachment is a document reports no images at all rather than one.
assert.equal(skoolImagesOf({
attachmentsData: JSON.stringify([
{ id: "a1", metadata: { content_type: "application/pdf", file_name: "spec.pdf", read_url: "https://assets.skool.com/f/g/spec.pdf" } },
]),
}), undefined);
// NOTHING IS NOT AN EMPTY ARRAY. `undefined` keeps the key off the row, and
// the face draws nothing rather than an empty frame.
assert.equal(skoolImagesOf({ videoIds: "", content: "words only" }), undefined);
assert.equal(skoolImagesOf(null), undefined);
// Malformed JSON is not a crash and not an invented picture: the preview the
// same payload carries is still honest.
assert.deepEqual(skoolImagesOf({ attachmentsData: "{not json", imagePreview: "https://assets.skool.com/f/g/one.png" }),
[{ url: "https://assets.skool.com/f/g/one.png" }]);
});
/* skool-comment-composer.css — SKOOL'S OWN COMMENT BOX, and only it.
*
* The community's desk and card colours are `skool-faces.css`'s
* (`--skool-desk`, `--skool-accent`); this file adds nothing but the box a
* person types a comment into, so the two families' looks cannot drift apart
* by being restated here ⟨CLAUDE.md §4⟩. */
.sk-composer {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
overflow: hidden;
max-width: 560px;
}
.sk-composer__where {
display: flex;
align-items: baseline;
gap: 6px;
padding: 10px 14px 0;
font-size: 12px;
color: var(--text-secondary);
min-width: 0;
}
.sk-composer__post {
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* THE BOX ITSELF — Skool draws a soft rounded field with the author's disc
* outside it on the left, which is what this is. */
.sk-composer__row {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px 12px;
}
.sk-composer__field {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface-sunken, var(--surface));
padding: 9px 14px;
}
.sk-composer__text {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sk-composer__as {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
/* NOT POSTED, said where a person cannot miss it. Skool comments are public
* and nothing on this hand deletes one. */
.sk-composer__pill {
flex: none;
margin-left: auto;
align-self: center;
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
color: var(--text-secondary);
}
/* skool-comment-composer.css — SKOOL'S OWN COMMENT BOX, and only it.
*
* The community's desk and card colours are `skool-faces.css`'s
* (`--skool-desk`, `--skool-accent`); this file adds nothing but the box a
* person types a comment into, so the two families' looks cannot drift apart
* by being restated here ⟨CLAUDE.md §4⟩. */
.sk-composer {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
overflow: hidden;
max-width: 560px;
}
.sk-composer__where {
display: flex;
align-items: baseline;
gap: 6px;
padding: 10px 14px 0;
font-size: 12px;
color: var(--text-secondary);
min-width: 0;
}
.sk-composer__post {
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* THE BOX ITSELF — Skool draws a soft rounded field with the author's disc
* outside it on the left, which is what this is. */
.sk-composer__row {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px 12px;
}
.sk-composer__field {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface-sunken, var(--surface));
padding: 9px 14px;
}
.sk-composer__text {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sk-composer__as {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
/* NOT POSTED, said where a person cannot miss it. Skool comments are public
* and nothing on this hand deletes one. */
.sk-composer__pill {
flex: none;
margin-left: auto;
align-self: center;
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
color: var(--text-secondary);
}
/**
* skool-comment-composer.tsx — THE COMMENT YOU ARE ABOUT TO LEAVE, IN THE
* CONVERSATION IT JOINS.
*
* ── THE GAP THIS CLOSES, MEASURED ⟨2026-09-09⟩ ──────────────────────────────
* `snappy-skool`'s `skoolCommentDecisionFace` has printed a complete composite
* for two days — the real comment rows, the draft, the priced doors — with
* `kind: null` and, in its place, this sentence:
*
* faceGap: "snappy-faces draws no Skool comment composer;
* the thread it joins is drawn by skool-comments"
*
* It was the ONLY `faceGap` left in the whole collection (grepped across every
* hand). Naming the gap was the right call at the time — a neighbouring kind
* borrowed to make a check pass is the status-truer-than-its-artifact defect
* ⟨CLAUDE.md §10⟩ — but what reached a person was the conversation and then
* NOTHING where the half they were being asked to decide should have been. This
* file is the half.
*
* ── IT DRAWS THE DRAFT ONLY. THE THREAD IS MOUNTED ABOVE IT ─────────────────
* ⟨the owner's shape law, 2026-09-09 01:5x: "you don't just show me the email
* you're going to send, you show it in the context; that is how you build
* trust"⟩ — and the context is already solved ONCE, for every family, by
* `widget-entry.tsx`'s `ThreadBand`: a payload carrying `thread` draws its
* family's registered `thread` member above the face, and Skool's is
* `skool-comments`. So this card must NOT redraw the comments. A composer that
* drew its own copy of the thread would be the second reader of one
* conversation ⟨CLAUDE.md §4⟩, and the two would disagree the first time either
* changed.
*
* ── EVERY PERSON HERE IS A `Person`, INCLUDING HIM ⟨2026-09-09 13:4x⟩ ───────
* The disc is `PersonAvatar` and it is handed `viewer` — the owner's own saved
* profile, injected onto every payload by `snappy-faces/api.ts#withViewer` and
* spread onto this view by `renderFace`. The hand prints the author as the word
* `"you"`, which names nobody; matched against `viewer` it becomes his photo and
* his name. Set the picture once in the bar's Account tab and the comment you
* are about to leave wears your face.
*
* ── NOTHING IS INVENTED TO FILL A FACE ⟨the standing law of this family⟩ ────
* No likes, no timestamp, no reply count: a comment that has not been posted has
* none of them, and a zero drawn here would be a measurement nobody took.
*/
import type { JSX } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import {
DoorRow, doorsOf, readDoors, useDoorPress, type ChatDoor,
} from "../../../snappy-faces/library/src/components/decision-shell.tsx";
import { doorShape as skoolDoorShape } from "../../../snappy-faces/library/src/components/door.ts";
import "../../../snappy-faces/library/src/components/destination-previews.css";
import "./skool-faces.css";
import "./skool-comment-composer.css";
const SKOOL = "skool.com";
export interface SkoolCommentComposerProps {
/** The words that would be posted. `text` and not `body` because that is the
* word the draft prints for the FACE; `body` rides beside it for the verb
* `comment <post> <body>`, and both are accepted here so a caller holding
* either spelling draws ⟨`skoolCommentDecisionFace`⟩. */
readonly text?: string | null;
readonly body?: string | null;
/** Who is commenting. The hand prints `"you"`; with a `viewer` that becomes
* his own name and photo, and without one it stays the honest "You". */
readonly author?: string | null;
readonly avatarUrl?: string | null;
/** The post being answered — its title when the read had one, otherwise the
* slug the verb takes. Drawn as the place, never as a headline. */
readonly post?: string | null;
/** The community the post lives in. */
readonly community?: string | null;
readonly viewer?: ViewerProfile | null;
readonly waitingWords?: string | null;
readonly doors?: readonly ChatDoor[] | null;
readonly decisionId?: string | null;
readonly onDoor?: ((id: string) => void) | undefined;
}
export function SkoolCommentComposerView(props: SkoolCommentComposerProps): JSX.Element {
const doors = doorsOf(props.doors);
const words = (props.text ?? props.body ?? "").toString();
// "you" IS NOT A NAME. The hand prints it because the connected account's own
// name costs a second read; `viewer` is what turns it into a person.
const printed = (props.author ?? "").trim();
const anonymous = printed === "" || printed.toLowerCase() === "you";
const viewerName = (props.viewer?.name ?? "").trim();
const author = anonymous ? (viewerName !== "" ? viewerName : "You") : printed;
const post = (props.post ?? "").trim();
const community = (props.community ?? "").trim();
return (
<div
className="chat-card-enter dest-preview-root sk-root sk-composer"
data-channel="skool-comment"
data-decision-id={props.decisionId ?? undefined}
>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{community === "" ? "Skool" : community}</span>
<span className="sk-composer__pill">Not posted</span>
</div>
{post === "" ? null : (
<div className="sk-composer__where">
<span>Commenting on</span>
<span className="sk-composer__post">{post}</span>
</div>
)}
<div className="sk-composer__row">
{/* HIS OWN DISC, when the person commenting is him ⟨person.tsx⟩. */}
<PersonAvatar
name={anonymous ? undefined : printed}
avatarUrl={props.avatarUrl}
me={anonymous ? true : undefined}
viewer={props.viewer}
className="sk-avatar sk-avatar--sm"
/>
<div className="sk-composer__field">
{/* `data-face-source` is the seam the widget's editor binds to, the
same one every other composer in this collection wears — the words
are edited HERE and the door sends what is on the face ⟨the owner,
2026-09-09 00:2x, deleting "Say instead"⟩. */}
<div className="sk-composer__text" data-face-source={words}>{words}</div>
<span className="sk-composer__as">as {author}</span>
</div>
</div>
<DoorRow doors={doors} onDoor={props.onDoor} waitingWords={props.waitingWords} />
</div>
);
}
export const SkoolCommentComposerComponent = defineComponent({
name: "SkoolCommentComposer",
description:
"USE FOR: a comment on a Skool post that is WAITING ON A PERSON — 'reply to that thread', 'answer them in the community', any staged `snappy-skool comment`. "
+ "Draws Skool's own comment box: the community's head, which post is being answered, the author's disc, the words in Skool's rounded field, and the two ways out inside the card — Comment · Later. "
+ "THE CONVERSATION IT JOINS IS MOUNTED ABOVE THIS FACE by the widget from the same rows `comments --json` prints, so this card never redraws the thread. "
+ "Compact call: SkoolCommentComposer(text). Optional and positional after text: author (omit, or pass 'you', and the connected account's own name and photo are used), post (the post's title or slug), community, avatarUrl, waitingWords, doors ([{label, price?, primary?, verb?}] as plain records, exactly one primary; absent gives Comment · Later), decisionId. "
+ "No likes and no timestamp: a comment that has not been posted has neither. For comments that already exist use SkoolCommentThread; for a whole new post use SkoolPostPreview.",
props: z.object({
text: z.string().nullish(),
body: z.string().nullish(),
author: z.string().nullish(),
avatarUrl: z.string().nullish(),
post: z.string().nullish(),
community: z.string().nullish(),
waitingWords: z.string().nullish(),
doors: z.array(skoolDoorShape).nullish(),
decisionId: z.string().nullish(),
action: z.unknown().nullish(),
}),
component: ({ props }): JSX.Element => {
const doors = doorsOf(readDoors(props.doors));
const press = useDoorPress(props.action, doors);
return (
<SkoolCommentComposerView
text={props.text}
body={props.body}
author={props.author}
avatarUrl={props.avatarUrl}
post={props.post}
community={props.community}
waitingWords={props.waitingWords}
doors={doors}
decisionId={props.decisionId}
onDoor={press}
/>
);
},
});
/**
* skool-comment-composer.tsx — THE COMMENT YOU ARE ABOUT TO LEAVE, IN THE
* CONVERSATION IT JOINS.
*
* ── THE GAP THIS CLOSES, MEASURED ⟨2026-09-09⟩ ──────────────────────────────
* `snappy-skool`'s `skoolCommentDecisionFace` has printed a complete composite
* for two days — the real comment rows, the draft, the priced doors — with
* `kind: null` and, in its place, this sentence:
*
* faceGap: "snappy-faces draws no Skool comment composer;
* the thread it joins is drawn by skool-comments"
*
* It was the ONLY `faceGap` left in the whole collection (grepped across every
* hand). Naming the gap was the right call at the time — a neighbouring kind
* borrowed to make a check pass is the status-truer-than-its-artifact defect
* ⟨CLAUDE.md §10⟩ — but what reached a person was the conversation and then
* NOTHING where the half they were being asked to decide should have been. This
* file is the half.
*
* ── IT DRAWS THE DRAFT ONLY. THE THREAD IS MOUNTED ABOVE IT ─────────────────
* ⟨the owner's shape law, 2026-09-09 01:5x: "you don't just show me the email
* you're going to send, you show it in the context; that is how you build
* trust"⟩ — and the context is already solved ONCE, for every family, by
* `widget-entry.tsx`'s `ThreadBand`: a payload carrying `thread` draws its
* family's registered `thread` member above the face, and Skool's is
* `skool-comments`. So this card must NOT redraw the comments. A composer that
* drew its own copy of the thread would be the second reader of one
* conversation ⟨CLAUDE.md §4⟩, and the two would disagree the first time either
* changed.
*
* ── EVERY PERSON HERE IS A `Person`, INCLUDING HIM ⟨2026-09-09 13:4x⟩ ───────
* The disc is `PersonAvatar` and it is handed `viewer` — the owner's own saved
* profile, injected onto every payload by `snappy-faces/api.ts#withViewer` and
* spread onto this view by `renderFace`. The hand prints the author as the word
* `"you"`, which names nobody; matched against `viewer` it becomes his photo and
* his name. Set the picture once in the bar's Account tab and the comment you
* are about to leave wears your face.
*
* ── NOTHING IS INVENTED TO FILL A FACE ⟨the standing law of this family⟩ ────
* No likes, no timestamp, no reply count: a comment that has not been posted has
* none of them, and a zero drawn here would be a measurement nobody took.
*/
import type { JSX } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import {
DoorRow, doorsOf, readDoors, useDoorPress, type ChatDoor,
} from "../../../snappy-faces/library/src/components/decision-shell.tsx";
import { doorShape as skoolDoorShape } from "../../../snappy-faces/library/src/components/door.ts";
import "../../../snappy-faces/library/src/components/destination-previews.css";
import "./skool-faces.css";
import "./skool-comment-composer.css";
const SKOOL = "skool.com";
export interface SkoolCommentComposerProps {
/** The words that would be posted. `text` and not `body` because that is the
* word the draft prints for the FACE; `body` rides beside it for the verb
* `comment <post> <body>`, and both are accepted here so a caller holding
* either spelling draws ⟨`skoolCommentDecisionFace`⟩. */
readonly text?: string | null;
readonly body?: string | null;
/** Who is commenting. The hand prints `"you"`; with a `viewer` that becomes
* his own name and photo, and without one it stays the honest "You". */
readonly author?: string | null;
readonly avatarUrl?: string | null;
/** The post being answered — its title when the read had one, otherwise the
* slug the verb takes. Drawn as the place, never as a headline. */
readonly post?: string | null;
/** The community the post lives in. */
readonly community?: string | null;
readonly viewer?: ViewerProfile | null;
readonly waitingWords?: string | null;
readonly doors?: readonly ChatDoor[] | null;
readonly decisionId?: string | null;
readonly onDoor?: ((id: string) => void) | undefined;
}
export function SkoolCommentComposerView(props: SkoolCommentComposerProps): JSX.Element {
const doors = doorsOf(props.doors);
const words = (props.text ?? props.body ?? "").toString();
// "you" IS NOT A NAME. The hand prints it because the connected account's own
// name costs a second read; `viewer` is what turns it into a person.
const printed = (props.author ?? "").trim();
const anonymous = printed === "" || printed.toLowerCase() === "you";
const viewerName = (props.viewer?.name ?? "").trim();
const author = anonymous ? (viewerName !== "" ? viewerName : "You") : printed;
const post = (props.post ?? "").trim();
const community = (props.community ?? "").trim();
return (
<div
className="chat-card-enter dest-preview-root sk-root sk-composer"
data-channel="skool-comment"
data-decision-id={props.decisionId ?? undefined}
>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{community === "" ? "Skool" : community}</span>
<span className="sk-composer__pill">Not posted</span>
</div>
{post === "" ? null : (
<div className="sk-composer__where">
<span>Commenting on</span>
<span className="sk-composer__post">{post}</span>
</div>
)}
<div className="sk-composer__row">
{/* HIS OWN DISC, when the person commenting is him ⟨person.tsx⟩. */}
<PersonAvatar
name={anonymous ? undefined : printed}
avatarUrl={props.avatarUrl}
me={anonymous ? true : undefined}
viewer={props.viewer}
className="sk-avatar sk-avatar--sm"
/>
<div className="sk-composer__field">
{/* `data-face-source` is the seam the widget's editor binds to, the
same one every other composer in this collection wears — the words
are edited HERE and the door sends what is on the face ⟨the owner,
2026-09-09 00:2x, deleting "Say instead"⟩. */}
<div className="sk-composer__text" data-face-source={words}>{words}</div>
<span className="sk-composer__as">as {author}</span>
</div>
</div>
<DoorRow doors={doors} onDoor={props.onDoor} waitingWords={props.waitingWords} />
</div>
);
}
export const SkoolCommentComposerComponent = defineComponent({
name: "SkoolCommentComposer",
description:
"USE FOR: a comment on a Skool post that is WAITING ON A PERSON — 'reply to that thread', 'answer them in the community', any staged `snappy-skool comment`. "
+ "Draws Skool's own comment box: the community's head, which post is being answered, the author's disc, the words in Skool's rounded field, and the two ways out inside the card — Comment · Later. "
+ "THE CONVERSATION IT JOINS IS MOUNTED ABOVE THIS FACE by the widget from the same rows `comments --json` prints, so this card never redraws the thread. "
+ "Compact call: SkoolCommentComposer(text). Optional and positional after text: author (omit, or pass 'you', and the connected account's own name and photo are used), post (the post's title or slug), community, avatarUrl, waitingWords, doors ([{label, price?, primary?, verb?}] as plain records, exactly one primary; absent gives Comment · Later), decisionId. "
+ "No likes and no timestamp: a comment that has not been posted has neither. For comments that already exist use SkoolCommentThread; for a whole new post use SkoolPostPreview.",
props: z.object({
text: z.string().nullish(),
body: z.string().nullish(),
author: z.string().nullish(),
avatarUrl: z.string().nullish(),
post: z.string().nullish(),
community: z.string().nullish(),
waitingWords: z.string().nullish(),
doors: z.array(skoolDoorShape).nullish(),
decisionId: z.string().nullish(),
action: z.unknown().nullish(),
}),
component: ({ props }): JSX.Element => {
const doors = doorsOf(readDoors(props.doors));
const press = useDoorPress(props.action, doors);
return (
<SkoolCommentComposerView
text={props.text}
body={props.body}
author={props.author}
avatarUrl={props.avatarUrl}
post={props.post}
community={props.community}
waitingWords={props.waitingWords}
doors={doors}
decisionId={props.decisionId}
onDoor={press}
/>
);
},
});
/* skool-faces.css — THE REST OF THE SKOOL FAMILY.
*
* ⟨THE OWNER, 2026-09-07 10:2x: "it is NOT ONE FACE, it is MANY faces; even for
* one platform they have multiple faces."⟩ `SkoolPostPreview` was the whole of
* Skool in this library — one post. But a community is a FEED of posts, a post
* has a COMMENT THREAD under it, and the classroom is a set of MODULES with
* lessons inside them. Those are three more objects a Skool run produces and
* none of them had a face.
*
* ONE PALETTE, TWO STYLESHEETS, AND THE SPLIT IS BY OWNERSHIP.
* `.skool-post-root` and its `--skool-accent` gold live in
* `destination-previews.css` with the other single-post destination faces, and
* they stay there — moving them would be a diff across every channel face for
* no gain. The three faces below are new class names with a new owner, so they
* get their own file (the css-single-owner gate's unit is the selector, and
* nothing here is declared anywhere else). They restate the same brand root
* because a stylesheet cannot inherit a selector; the VALUES are the same
* values, and the note above each says so.
*
* COLOURS ARE oklch, NEVER HEX ⟨scripts/gates/color-literals.mjs⟩.
*/
.sk-root {
/* The same three lines `.skool-post-root` declares, for the same reason:
Skool is a white-card product ⟨Robert, 2026-08-07: "for skool it should be
white background"⟩, so the embed keeps the platform's own surface in BOTH
app themes while the app's chrome around it stays themed. */
--skool-accent: oklch(0.85 0.14 90);
--skool-desk: oklch(0.972 0.002 260);
background: oklch(1 0 0);
color: oklch(0.24 0.01 260);
--text: oklch(0.24 0.01 260);
--text-secondary: oklch(0.45 0.01 260);
--text-tertiary: oklch(0.55 0.01 260);
--border-subtle: oklch(0.9 0.005 260);
--surface-elevated: oklch(1 0 0);
--surface-sunken: oklch(0.97 0.003 260);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
}
.sk-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.sk-head-name { font-size: 15px; font-weight: 700; flex: 1; min-width: 0; }
.sk-head-sub { font-size: 12px; color: var(--text-secondary); }
.sk-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
font-weight: 600;
font-size: 14px;
color: oklch(0.99 0 0);
overflow: hidden;
}
.sk-avatar--sm { width: 28px; height: 28px; font-size: 12px; }
.sk-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* ── SkoolFeed — the community's list of posts ────────────────────────────── */
.sk-feed { background: var(--skool-desk); padding: 10px; display: grid; gap: 10px; }
.sk-feed-card {
background: oklch(1 0 0);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 12px 14px;
}
.sk-feed-who { display: flex; align-items: center; gap: 10px; }
.sk-feed-name { font-size: 13px; font-weight: 600; }
.sk-feed-when { font-size: 12px; color: var(--text-secondary); }
/* The category label is Skool's own outlined pill and it is the one place the
brand gold appears in a feed — mixed toward the ink so it clears AA on white,
the same correction `.skool-post-where b` records. */
.sk-label {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--skool-accent) 40%, var(--border-subtle));
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--skool-accent) 45%, var(--text));
}
.sk-feed-title { margin: 8px 0 0; font-size: 15px; font-weight: 700; line-height: 1.3; }
.sk-feed-excerpt {
margin-top: 4px;
font-size: 14px;
line-height: 1.45;
color: var(--text-secondary);
/* A feed shows an excerpt; the whole post belongs to SkoolPostPreview. Two
lines is what Skool itself shows before "read more". */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sk-feed-counts {
display: flex;
gap: 14px;
margin-top: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.sk-pinned {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--skool-accent) 45%, var(--text));
}
/* ── SkoolCommentThread ───────────────────────────────────────────────────── */
.sk-comments { padding: 12px 14px 14px; }
.sk-comment { display: flex; gap: 10px; padding: 8px 0; }
.sk-comment-main { min-width: 0; flex: 1; }
.sk-comment-bubble {
background: var(--surface-sunken);
border-radius: 10px;
padding: 9px 12px;
}
.sk-comment-name { font-size: 13px; font-weight: 600; }
.sk-comment-text {
font-size: 14px;
line-height: 1.45;
margin-top: 2px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sk-comment-foot {
display: flex;
gap: 14px;
margin: 5px 0 0 12px;
font-size: 12px;
color: var(--text-secondary);
}
.sk-comment-replies {
margin-top: 6px;
padding-left: 14px;
border-left: 2px solid var(--border-subtle);
}
/* ── SkoolClassroomModule ─────────────────────────────────────────────────── */
.sk-module-root { max-width: 520px; }
.sk-module-cover {
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-sunken);
display: grid;
place-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.sk-module-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sk-module-body { padding: 14px; }
.sk-module-title { margin: 0; font-size: 17px; font-weight: 700; line-height: 1.25; }
.sk-module-about {
margin-top: 5px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
/* THE PROGRESS BAR IS SKOOL'S OWN and it is drawn ONLY when a real completed
* count was passed. A zero-width bar over an unknown number is a claim nobody
* measured, so the whole bar is absent instead. */
.sk-progress {
margin-top: 12px;
height: 6px;
border-radius: 999px;
background: var(--surface-sunken);
overflow: hidden;
}
.sk-progress-fill { height: 100%; background: var(--skool-accent); }
.sk-progress-words { margin-top: 6px; font-size: 12px; color: var(--text-secondary); }
.sk-lessons { margin: 12px 0 0; padding: 0; list-style: none; }
.sk-lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid var(--border-subtle);
font-size: 14px;
}
.sk-lesson-tick {
flex: none;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid var(--border-subtle);
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
color: oklch(1 0 0);
}
.sk-lesson-tick[data-done="true"] {
background: color-mix(in oklab, var(--skool-accent) 70%, var(--text));
border-color: transparent;
}
.sk-lesson-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.sk-lesson-len { flex: none; font-size: 12px; color: var(--text-secondary); }
/* skool-faces.css — THE REST OF THE SKOOL FAMILY.
*
* ⟨THE OWNER, 2026-09-07 10:2x: "it is NOT ONE FACE, it is MANY faces; even for
* one platform they have multiple faces."⟩ `SkoolPostPreview` was the whole of
* Skool in this library — one post. But a community is a FEED of posts, a post
* has a COMMENT THREAD under it, and the classroom is a set of MODULES with
* lessons inside them. Those are three more objects a Skool run produces and
* none of them had a face.
*
* ONE PALETTE, TWO STYLESHEETS, AND THE SPLIT IS BY OWNERSHIP.
* `.skool-post-root` and its `--skool-accent` gold live in
* `destination-previews.css` with the other single-post destination faces, and
* they stay there — moving them would be a diff across every channel face for
* no gain. The three faces below are new class names with a new owner, so they
* get their own file (the css-single-owner gate's unit is the selector, and
* nothing here is declared anywhere else). They restate the same brand root
* because a stylesheet cannot inherit a selector; the VALUES are the same
* values, and the note above each says so.
*
* COLOURS ARE oklch, NEVER HEX ⟨scripts/gates/color-literals.mjs⟩.
*/
.sk-root {
/* The same three lines `.skool-post-root` declares, for the same reason:
Skool is a white-card product ⟨Robert, 2026-08-07: "for skool it should be
white background"⟩, so the embed keeps the platform's own surface in BOTH
app themes while the app's chrome around it stays themed. */
--skool-accent: oklch(0.85 0.14 90);
--skool-desk: oklch(0.972 0.002 260);
background: oklch(1 0 0);
color: oklch(0.24 0.01 260);
--text: oklch(0.24 0.01 260);
--text-secondary: oklch(0.45 0.01 260);
--text-tertiary: oklch(0.55 0.01 260);
--border-subtle: oklch(0.9 0.005 260);
--surface-elevated: oklch(1 0 0);
--surface-sunken: oklch(0.97 0.003 260);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
}
.sk-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.sk-head-name { font-size: 15px; font-weight: 700; flex: 1; min-width: 0; }
.sk-head-sub { font-size: 12px; color: var(--text-secondary); }
.sk-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
font-weight: 600;
font-size: 14px;
color: oklch(0.99 0 0);
overflow: hidden;
}
.sk-avatar--sm { width: 28px; height: 28px; font-size: 12px; }
.sk-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* ── SkoolFeed — the community's list of posts ────────────────────────────── */
.sk-feed { background: var(--skool-desk); padding: 10px; display: grid; gap: 10px; }
.sk-feed-card {
background: oklch(1 0 0);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 12px 14px;
}
.sk-feed-who { display: flex; align-items: center; gap: 10px; }
.sk-feed-name { font-size: 13px; font-weight: 600; }
.sk-feed-when { font-size: 12px; color: var(--text-secondary); }
/* The category label is Skool's own outlined pill and it is the one place the
brand gold appears in a feed — mixed toward the ink so it clears AA on white,
the same correction `.skool-post-where b` records. */
.sk-label {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--skool-accent) 40%, var(--border-subtle));
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--skool-accent) 45%, var(--text));
}
.sk-feed-title { margin: 8px 0 0; font-size: 15px; font-weight: 700; line-height: 1.3; }
.sk-feed-excerpt {
margin-top: 4px;
font-size: 14px;
line-height: 1.45;
color: var(--text-secondary);
/* A feed shows an excerpt; the whole post belongs to SkoolPostPreview. Two
lines is what Skool itself shows before "read more". */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sk-feed-counts {
display: flex;
gap: 14px;
margin-top: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.sk-pinned {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--skool-accent) 45%, var(--text));
}
/* ── SkoolCommentThread ───────────────────────────────────────────────────── */
.sk-comments { padding: 12px 14px 14px; }
.sk-comment { display: flex; gap: 10px; padding: 8px 0; }
.sk-comment-main { min-width: 0; flex: 1; }
.sk-comment-bubble {
background: var(--surface-sunken);
border-radius: 10px;
padding: 9px 12px;
}
.sk-comment-name { font-size: 13px; font-weight: 600; }
.sk-comment-text {
font-size: 14px;
line-height: 1.45;
margin-top: 2px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sk-comment-foot {
display: flex;
gap: 14px;
margin: 5px 0 0 12px;
font-size: 12px;
color: var(--text-secondary);
}
.sk-comment-replies {
margin-top: 6px;
padding-left: 14px;
border-left: 2px solid var(--border-subtle);
}
/* ── SkoolClassroomModule ─────────────────────────────────────────────────── */
.sk-module-root { max-width: 520px; }
.sk-module-cover {
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-sunken);
display: grid;
place-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.sk-module-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sk-module-body { padding: 14px; }
.sk-module-title { margin: 0; font-size: 17px; font-weight: 700; line-height: 1.25; }
.sk-module-about {
margin-top: 5px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
}
/* THE PROGRESS BAR IS SKOOL'S OWN and it is drawn ONLY when a real completed
* count was passed. A zero-width bar over an unknown number is a claim nobody
* measured, so the whole bar is absent instead. */
.sk-progress {
margin-top: 12px;
height: 6px;
border-radius: 999px;
background: var(--surface-sunken);
overflow: hidden;
}
.sk-progress-fill { height: 100%; background: var(--skool-accent); }
.sk-progress-words { margin-top: 6px; font-size: 12px; color: var(--text-secondary); }
.sk-lessons { margin: 12px 0 0; padding: 0; list-style: none; }
.sk-lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid var(--border-subtle);
font-size: 14px;
}
.sk-lesson-tick {
flex: none;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid var(--border-subtle);
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
color: oklch(1 0 0);
}
.sk-lesson-tick[data-done="true"] {
background: color-mix(in oklab, var(--skool-accent) 70%, var(--text));
border-color: transparent;
}
.sk-lesson-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.sk-lesson-len { flex: none; font-size: 12px; color: var(--text-secondary); }
/**
* skool-faces.tsx — THE REST OF THE SKOOL FAMILY.
*
* ⟨THE OWNER, 2026-09-07 10:2x: "it is NOT ONE FACE, it is MANY faces; even for
* one platform they have multiple faces."⟩ Until today Skool was ONE face in
* this library — `SkoolPostPreview`, a single staged post. A community manager
* does not only draft posts: they read the FEED, they read the COMMENT THREAD
* under a post, and they build the CLASSROOM. Those three had no face, so a
* run that produced any of them came back as grey markdown.
*
* NO SIBLING OF `SkoolPostPreview` LIVES HERE. The brief asked for a `SkoolPost`
* and this file deliberately does not define one: `SkoolPostPreview` (moved into
* this package beside this file on 2026-09-07) already IS "a Skool post as Skool
* draws it", and a second component with the same job under a different name is
* the duplicate road CLAUDE.md §4 bans. `SkoolFeed` shows the community's posts
* as EXCERPTS — a different job, which is why it is a different face.
*
* NOTHING IS INVENTED TO FILL A FACE — the standing law of this family. No
* completed count → no progress bar, because a zero-width bar over an unknown
* number is a claim nobody measured. No cover → the words, never an empty box.
*/
import type { JSX } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { Media, type MediaItem } from "../../../snappy-faces/library/src/components/post-media.tsx";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import { formatSocialCount } from "../../../snappy-faces/library/src/components/social-card-format";
import "../../../snappy-faces/library/src/components/destination-previews.css";
import "./skool-faces.css";
const SKOOL = "skool.com";
/** SKOOL'S OWN AGE SPELLING — "3d", "2w", the compact form its feed prints, and
* deliberately not YouTube's "3 weeks ago". Each platform prints time its own
* way; one shared formatter would make both faces slightly wrong. */
function shortAge(iso: string | undefined, now: number): string {
if (typeof iso !== "string" || iso.trim() === "") return "";
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const mins = Math.max(0, Math.round((now - then) / 60000));
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
return weeks < 52 ? `${weeks}w` : `${Math.floor(days / 365)}y`;
}
// ── SkoolFeed ───────────────────────────────────────────────────────────────
export interface SkoolFeedPost {
readonly title: string;
readonly excerpt?: string;
readonly authorName?: string;
readonly authorAvatarUrl?: string;
readonly labelName?: string;
readonly postedAt?: string;
readonly likesCount?: number;
readonly commentsCount?: number;
readonly pinned?: boolean;
/** THE POST'S OWN SLUG, which is how snappy-skool addresses one post
* (`post <slug>`, `comments <slug>`, `thread <slug>` — its contract says
* "from a `posts` row's `slug`"). The hand has always answered it; this face
* simply dropped it, so a feed row could not open the post it drew. */
readonly slug?: string;
/** THE PICTURES ON THE POST ⟨the owner, 2026-09-09 13:4x: "the images are not
* shown here which looks really bad"⟩. Skool's feed is photographs — a build
* log, a finished jig, a shot of the bench — and this face drew the words
* and dropped every one of them. */
readonly images?: readonly MediaItem[];
}
export interface SkoolFeedProps {
posts: readonly SkoolFeedPost[];
communityName?: string;
memberCount?: number;
/** Skool's own count for the community, when the read returned fewer posts
* than exist. The hand already prints it as `total`. */
total?: number;
now?: number;
/** The owner's own profile, so a post HE wrote wears the photo he set once
* ⟨`person.tsx`⟩. A caller without one passes nothing and every author keeps
* the photo the read carried. */
viewer?: ViewerProfile | null;
}
export function SkoolFeedView(props: SkoolFeedProps): JSX.Element {
const now = props.now ?? Date.now();
const posts = (props.posts ?? []).filter(
(p) => p && typeof p.title === "string" && p.title.trim() !== "",
);
const members = formatSocialCount(props.memberCount);
// WHAT THE READ RETURNED, AND WHAT THE COMMUNITY HOLDS ⟨the owner, 2026-09-09
// 01:5x⟩. `total` is Skool's own count when the read reported one; without it
// the face states only what it drew, and never guesses the rest.
const total = typeof props.total === "number" && Number.isFinite(props.total) && props.total > posts.length
? props.total
: null;
const count = posts.length === 0 ? "" : total === null ? `${posts.length} posts` : `${posts.length} of ${total} posts`;
return (
<div className="chat-card-enter dest-preview-root sk-root" data-channel="skool-feed" data-count={posts.length}>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{props.communityName ?? "Community"}</span>
{members ? <span className="sk-head-sub">{members} members</span> : null}
{count === "" ? null : <span className="sk-head-sub">{count}</span>}
</div>
<div className="sk-feed">
{posts.length === 0 ? (
<div className="sk-feed-card"><div className="sk-feed-excerpt">No posts were returned for this community.</div></div>
) : posts.map((p, i) => {
const likes = formatSocialCount(p.likesCount);
const comments = formatSocialCount(p.commentsCount);
const when = shortAge(p.postedAt, now);
return (
// THE CARD OPENS THE POST ⟨lane list-rows, 2026-09-09⟩:
// `snappy-skool post <slug>`, a READ, drawn as `skool-post`.
<article className="sk-feed-card" key={`${i}:${p.title}`}
{...rowPressProps("skool-feed", p as unknown as Record<string, unknown>)}>
<div className="sk-feed-who">
<PersonAvatar name={p.authorName} avatarUrl={p.authorAvatarUrl} viewer={props.viewer} className="sk-avatar sk-avatar--sm" />
<div>
{p.authorName ? <div className="sk-feed-name">{p.authorName}</div> : null}
{when ? <div className="sk-feed-when">{when} ago</div> : null}
</div>
{p.pinned ? <span className="sk-pinned">📌 Pinned</span> : null}
{p.labelName ? <span className="sk-label">{p.labelName}</span> : null}
</div>
<h4 className="sk-feed-title">{p.title}</h4>
{p.excerpt ? <p className="sk-feed-excerpt">{p.excerpt}</p> : null}
<Media items={p.images} className="sk-feed-media" />
{likes || comments ? (
<div className="sk-feed-counts">
{likes ? <span>{likes} likes</span> : null}
{comments ? <span>{comments} comments</span> : null}
</div>
) : null}
</article>
);
})}
</div>
</div>
);
}
export const SkoolFeedComponent = defineComponent({
name: "SkoolFeed",
description:
"USE FOR: 'what's happening in the community', 'show me the Skool feed', 'what did people post this week', 'catch me up on Skool'. The community's feed as Skool draws it: white post cards on the desk, each with author, age, category pill, title and a two-line excerpt, then the like and comment counts. Compact call: SkoolFeed(posts) where posts is an array of {title, slug?, excerpt?, authorName?, authorAvatarUrl?, labelName?, postedAt?, likesCount?, commentsCount?, pinned?, images?}. images is [{url, alt?}] — the post's own photographs, drawn as the platform draws them: one full width, two to four as a grid, more as a grid with a +N. PASS `slug` — it is the post's own id in every Skool read, and with it a feed card OPENS: pressing one runs `snappy-skool post <slug>` and the answer draws as SkoolPostPreview. labelName is the category's WORDS, never an id. Counts render only when given — never pass 0 to mean unknown. Optional and positional after posts: communityName, memberCount, total (the community's OWN post count when the read returned fewer than exist — the head then reads '20 of 87 posts' rather than stating only what it drew). Pass every post the read returned, up to twenty; three rows over a busy community is not the community. Use SkoolPostPreview when ONE post is the object; this is the list.",
props: z.object({
posts: z.array(z.object({
title: z.string(),
slug: z.string().nullish(),
excerpt: z.string().nullish(),
authorName: z.string().nullish(),
authorAvatarUrl: z.string().nullish(),
labelName: z.string().nullish(),
postedAt: z.string().nullish(),
likesCount: z.number().nullish(),
commentsCount: z.number().nullish(),
pinned: z.boolean().nullish(),
images: z.array(z.object({ url: z.string(), alt: z.string().nullish() })).nullish(),
})),
communityName: z.string().nullish(),
memberCount: z.number().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolFeedView
posts={(props.posts ?? []).map((p) => ({
title: p.title,
slug: p.slug ?? undefined,
excerpt: p.excerpt ?? undefined,
authorName: p.authorName ?? undefined,
authorAvatarUrl: p.authorAvatarUrl ?? undefined,
labelName: p.labelName ?? undefined,
postedAt: p.postedAt ?? undefined,
likesCount: p.likesCount ?? undefined,
commentsCount: p.commentsCount ?? undefined,
pinned: p.pinned ?? undefined,
images: (p.images ?? undefined)?.map((i) => ({ url: i.url, alt: i.alt ?? undefined })),
}))}
communityName={props.communityName ?? undefined}
memberCount={props.memberCount ?? undefined}
total={props.total ?? undefined}
/>
),
});
// ── SkoolCommentThread ──────────────────────────────────────────────────────
export interface SkoolComment {
readonly author: string;
readonly text: string;
readonly likes?: number;
readonly postedAt?: string;
readonly avatarUrl?: string;
readonly replies?: readonly SkoolComment[];
}
export interface SkoolCommentThreadProps {
comments: readonly SkoolComment[];
postTitle?: string;
communityName?: string;
now?: number;
/** The owner's own profile ⟨2026-09-09 13:4x⟩. A comment HE wrote wears the
* photo he set once; everyone else keeps the photo the read carried. It
* reaches this view because `renderFace` spreads the whole payload and
* `snappy-faces/api.ts#withViewer` puts it there — a face that does not
* DECLARE the prop drops it silently, which is what every face but the feed
* was doing. */
viewer?: ViewerProfile | null;
}
function SkoolCommentRow(
{ c, now, nested, viewer }: { c: SkoolComment; now: number; nested?: boolean; viewer?: ViewerProfile | null },
): JSX.Element {
const when = shortAge(c.postedAt, now);
const likes = formatSocialCount(c.likes);
const replies = (c.replies ?? []).filter((r) => r && typeof r.text === "string");
return (
<div className="sk-comment">
<PersonAvatar name={c.author} avatarUrl={c.avatarUrl} viewer={viewer} className={nested ? "sk-avatar sk-avatar--sm" : "sk-avatar"} />
<div className="sk-comment-main">
<div className="sk-comment-bubble">
<div className="sk-comment-name">{c.author}</div>
<div className="sk-comment-text">{c.text}</div>
</div>
<div className="sk-comment-foot">
{when ? <span>{when}</span> : null}
<span>{likes ? `${likes} likes` : "Like"}</span>
<span>Reply</span>
</div>
{replies.length > 0 ? (
<div className="sk-comment-replies">
{replies.map((r, i) => <SkoolCommentRow c={r} now={now} nested viewer={viewer} key={`${i}:${r.author}`} />)}
</div>
) : null}
</div>
</div>
);
}
export function SkoolCommentThreadView(props: SkoolCommentThreadProps): JSX.Element {
const now = props.now ?? Date.now();
const rows = (props.comments ?? []).filter(
(c) => c && typeof c.text === "string" && c.text.trim() !== "",
);
// HOW MANY, COUNTING REPLIES ⟨the owner, 2026-09-09 01:5x: "you see 20 emails
// pop up in front of you, not three"⟩. A thread drawn with no count leaves a
// person unable to tell a whole conversation from the top of one.
const total = rows.reduce((n, c) => n + 1 + (c.replies ?? []).length, 0);
return (
<div className="chat-card-enter dest-preview-root sk-root" data-channel="skool-comment-thread" data-count={total}>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{props.postTitle ?? "Comments"}</span>
{props.communityName ? <span className="sk-head-sub">{props.communityName}</span> : null}
{total === 0 ? null : <span className="sk-head-sub">{total} {total === 1 ? "comment" : "comments"}</span>}
</div>
<div className="sk-comments">
{rows.length === 0
? <p className="sk-feed-excerpt">No comments were returned for this post.</p>
: rows.map((c, i) => <SkoolCommentRow c={c} now={now} viewer={props.viewer} key={`${i}:${c.author}`} />)}
</div>
</div>
);
}
export const SkoolCommentThreadComponent = defineComponent({
name: "SkoolCommentThread",
description:
"USE FOR: 'what did people say on that post', 'show me the Skool comments', 'who needs a reply in the community'. The comment thread under a Skool post: bubbles with the author's name, the comment, its age and like count, and replies indented under their parent. Compact call: SkoolCommentThread(comments) where comments is an array of {author, text} and optionally {likes, postedAt, avatarUrl, replies}. replies is the same shape, one level deep — which is what Skool draws. Optional and positional after comments: postTitle, communityName. An empty array draws the honest 'no comments were returned', never an invented one.",
props: z.object({
comments: z.array(z.object({
author: z.string(),
text: z.string(),
likes: z.number().nullish(),
postedAt: z.string().nullish(),
avatarUrl: z.string().nullish(),
replies: z.array(z.object({
author: z.string(),
text: z.string(),
likes: z.number().nullish(),
postedAt: z.string().nullish(),
avatarUrl: z.string().nullish(),
})).nullish(),
})),
postTitle: z.string().nullish(),
communityName: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolCommentThreadView
comments={(props.comments ?? []).map((c) => ({
author: c.author,
text: c.text,
likes: c.likes ?? undefined,
postedAt: c.postedAt ?? undefined,
avatarUrl: c.avatarUrl ?? undefined,
replies: (c.replies ?? undefined)?.map((r) => ({
author: r.author,
text: r.text,
likes: r.likes ?? undefined,
postedAt: r.postedAt ?? undefined,
avatarUrl: r.avatarUrl ?? undefined,
})),
}))}
postTitle={props.postTitle ?? undefined}
communityName={props.communityName ?? undefined}
/>
),
});
// ── SkoolClassroomModule ────────────────────────────────────────────────────
export interface SkoolLesson {
readonly title: string;
readonly durationWords?: string;
readonly completed?: boolean;
}
export interface SkoolClassroomModuleProps {
title: string;
lessons: readonly SkoolLesson[];
description?: string;
coverUrl?: string;
/** How many lessons a member has finished. A REAL number or absent — absent
* draws no progress bar at all, because a zero-width bar is a measurement. */
completedCount?: number;
}
export function SkoolClassroomModuleView(props: SkoolClassroomModuleProps): JSX.Element {
const lessons = (props.lessons ?? []).filter(
(l) => l && typeof l.title === "string" && l.title.trim() !== "",
);
const cover = typeof props.coverUrl === "string" && /^(https?:|data:)/i.test(props.coverUrl.trim());
const known = typeof props.completedCount === "number"
&& Number.isFinite(props.completedCount)
&& lessons.length > 0;
const done = known ? Math.max(0, Math.min(lessons.length, Math.round(props.completedCount!))) : 0;
const pct = known ? Math.round((done / lessons.length) * 100) : 0;
return (
<div className="chat-card-enter dest-preview-root sk-root sk-module-root" data-channel="skool-classroom-module">
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">Classroom</span>
<span className="sk-head-sub">{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
</div>
<div className="sk-module-cover">
{cover ? <img src={props.coverUrl} alt="" /> : <span>No module cover was made yet.</span>}
</div>
<div className="sk-module-body">
<h4 className="sk-module-title">{props.title}</h4>
{props.description ? <p className="sk-module-about">{props.description}</p> : null}
{known ? (
<>
<div className="sk-progress"><div className="sk-progress-fill" style={{ width: `${pct}%` }} /></div>
<div className="sk-progress-words">{done} of {lessons.length} complete</div>
</>
) : null}
{lessons.length === 0 ? (
<p className="sk-module-about">This module has no lessons yet.</p>
) : (
<ul className="sk-lessons">
{lessons.map((l, i) => (
<li className="sk-lesson" key={`${i}:${l.title}`}>
<span className="sk-lesson-tick" data-done={l.completed ? "true" : "false"} aria-hidden="true">
{l.completed ? "✓" : ""}
</span>
<span className="sk-lesson-name">{l.title}</span>
{l.durationWords ? <span className="sk-lesson-len">{l.durationWords}</span> : null}
</li>
))}
</ul>
)}
</div>
</div>
);
}
export const SkoolClassroomModuleComponent = defineComponent({
name: "SkoolClassroomModule",
description:
"USE FOR: 'the classroom module', 'outline the course', 'what lessons are in the module', 'plan the Skool course'. One Skool classroom module drawn as Skool draws it: the 16:9 module cover, the module title and blurb, the completion bar, and the lesson list with its ticks. Compact call: SkoolClassroomModule(title, lessons) where lessons is an array of {title, durationWords?, completed?} — durationWords is words a person reads ('8 min'), not a number. Optional and positional after lessons: description, coverUrl (omit and the face says no cover was made rather than drawing an empty box), completedCount (a REAL count of finished lessons; omit it and NO progress bar is drawn at all, because a zero-width bar is a measurement nobody took).",
props: z.object({
title: z.string(),
lessons: z.array(z.object({
title: z.string(),
durationWords: z.string().nullish(),
completed: z.boolean().nullish(),
})),
description: z.string().nullish(),
coverUrl: z.string().nullish(),
completedCount: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolClassroomModuleView
title={props.title}
lessons={(props.lessons ?? []).map((l) => ({
title: l.title,
durationWords: l.durationWords ?? undefined,
completed: l.completed ?? undefined,
}))}
description={props.description ?? undefined}
coverUrl={props.coverUrl ?? undefined}
completedCount={props.completedCount ?? undefined}
/>
),
});
/**
* skool-faces.tsx — THE REST OF THE SKOOL FAMILY.
*
* ⟨THE OWNER, 2026-09-07 10:2x: "it is NOT ONE FACE, it is MANY faces; even for
* one platform they have multiple faces."⟩ Until today Skool was ONE face in
* this library — `SkoolPostPreview`, a single staged post. A community manager
* does not only draft posts: they read the FEED, they read the COMMENT THREAD
* under a post, and they build the CLASSROOM. Those three had no face, so a
* run that produced any of them came back as grey markdown.
*
* NO SIBLING OF `SkoolPostPreview` LIVES HERE. The brief asked for a `SkoolPost`
* and this file deliberately does not define one: `SkoolPostPreview` (moved into
* this package beside this file on 2026-09-07) already IS "a Skool post as Skool
* draws it", and a second component with the same job under a different name is
* the duplicate road CLAUDE.md §4 bans. `SkoolFeed` shows the community's posts
* as EXCERPTS — a different job, which is why it is a different face.
*
* NOTHING IS INVENTED TO FILL A FACE — the standing law of this family. No
* completed count → no progress bar, because a zero-width bar over an unknown
* number is a claim nobody measured. No cover → the words, never an empty box.
*/
import type { JSX } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { Media, type MediaItem } from "../../../snappy-faces/library/src/components/post-media.tsx";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import { formatSocialCount } from "../../../snappy-faces/library/src/components/social-card-format";
import "../../../snappy-faces/library/src/components/destination-previews.css";
import "./skool-faces.css";
const SKOOL = "skool.com";
/** SKOOL'S OWN AGE SPELLING — "3d", "2w", the compact form its feed prints, and
* deliberately not YouTube's "3 weeks ago". Each platform prints time its own
* way; one shared formatter would make both faces slightly wrong. */
function shortAge(iso: string | undefined, now: number): string {
if (typeof iso !== "string" || iso.trim() === "") return "";
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const mins = Math.max(0, Math.round((now - then) / 60000));
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
return weeks < 52 ? `${weeks}w` : `${Math.floor(days / 365)}y`;
}
// ── SkoolFeed ───────────────────────────────────────────────────────────────
export interface SkoolFeedPost {
readonly title: string;
readonly excerpt?: string;
readonly authorName?: string;
readonly authorAvatarUrl?: string;
readonly labelName?: string;
readonly postedAt?: string;
readonly likesCount?: number;
readonly commentsCount?: number;
readonly pinned?: boolean;
/** THE POST'S OWN SLUG, which is how snappy-skool addresses one post
* (`post <slug>`, `comments <slug>`, `thread <slug>` — its contract says
* "from a `posts` row's `slug`"). The hand has always answered it; this face
* simply dropped it, so a feed row could not open the post it drew. */
readonly slug?: string;
/** THE PICTURES ON THE POST ⟨the owner, 2026-09-09 13:4x: "the images are not
* shown here which looks really bad"⟩. Skool's feed is photographs — a build
* log, a finished jig, a shot of the bench — and this face drew the words
* and dropped every one of them. */
readonly images?: readonly MediaItem[];
}
export interface SkoolFeedProps {
posts: readonly SkoolFeedPost[];
communityName?: string;
memberCount?: number;
/** Skool's own count for the community, when the read returned fewer posts
* than exist. The hand already prints it as `total`. */
total?: number;
now?: number;
/** The owner's own profile, so a post HE wrote wears the photo he set once
* ⟨`person.tsx`⟩. A caller without one passes nothing and every author keeps
* the photo the read carried. */
viewer?: ViewerProfile | null;
}
export function SkoolFeedView(props: SkoolFeedProps): JSX.Element {
const now = props.now ?? Date.now();
const posts = (props.posts ?? []).filter(
(p) => p && typeof p.title === "string" && p.title.trim() !== "",
);
const members = formatSocialCount(props.memberCount);
// WHAT THE READ RETURNED, AND WHAT THE COMMUNITY HOLDS ⟨the owner, 2026-09-09
// 01:5x⟩. `total` is Skool's own count when the read reported one; without it
// the face states only what it drew, and never guesses the rest.
const total = typeof props.total === "number" && Number.isFinite(props.total) && props.total > posts.length
? props.total
: null;
const count = posts.length === 0 ? "" : total === null ? `${posts.length} posts` : `${posts.length} of ${total} posts`;
return (
<div className="chat-card-enter dest-preview-root sk-root" data-channel="skool-feed" data-count={posts.length}>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{props.communityName ?? "Community"}</span>
{members ? <span className="sk-head-sub">{members} members</span> : null}
{count === "" ? null : <span className="sk-head-sub">{count}</span>}
</div>
<div className="sk-feed">
{posts.length === 0 ? (
<div className="sk-feed-card"><div className="sk-feed-excerpt">No posts were returned for this community.</div></div>
) : posts.map((p, i) => {
const likes = formatSocialCount(p.likesCount);
const comments = formatSocialCount(p.commentsCount);
const when = shortAge(p.postedAt, now);
return (
// THE CARD OPENS THE POST ⟨lane list-rows, 2026-09-09⟩:
// `snappy-skool post <slug>`, a READ, drawn as `skool-post`.
<article className="sk-feed-card" key={`${i}:${p.title}`}
{...rowPressProps("skool-feed", p as unknown as Record<string, unknown>)}>
<div className="sk-feed-who">
<PersonAvatar name={p.authorName} avatarUrl={p.authorAvatarUrl} viewer={props.viewer} className="sk-avatar sk-avatar--sm" />
<div>
{p.authorName ? <div className="sk-feed-name">{p.authorName}</div> : null}
{when ? <div className="sk-feed-when">{when} ago</div> : null}
</div>
{p.pinned ? <span className="sk-pinned">📌 Pinned</span> : null}
{p.labelName ? <span className="sk-label">{p.labelName}</span> : null}
</div>
<h4 className="sk-feed-title">{p.title}</h4>
{p.excerpt ? <p className="sk-feed-excerpt">{p.excerpt}</p> : null}
<Media items={p.images} className="sk-feed-media" />
{likes || comments ? (
<div className="sk-feed-counts">
{likes ? <span>{likes} likes</span> : null}
{comments ? <span>{comments} comments</span> : null}
</div>
) : null}
</article>
);
})}
</div>
</div>
);
}
export const SkoolFeedComponent = defineComponent({
name: "SkoolFeed",
description:
"USE FOR: 'what's happening in the community', 'show me the Skool feed', 'what did people post this week', 'catch me up on Skool'. The community's feed as Skool draws it: white post cards on the desk, each with author, age, category pill, title and a two-line excerpt, then the like and comment counts. Compact call: SkoolFeed(posts) where posts is an array of {title, slug?, excerpt?, authorName?, authorAvatarUrl?, labelName?, postedAt?, likesCount?, commentsCount?, pinned?, images?}. images is [{url, alt?}] — the post's own photographs, drawn as the platform draws them: one full width, two to four as a grid, more as a grid with a +N. PASS `slug` — it is the post's own id in every Skool read, and with it a feed card OPENS: pressing one runs `snappy-skool post <slug>` and the answer draws as SkoolPostPreview. labelName is the category's WORDS, never an id. Counts render only when given — never pass 0 to mean unknown. Optional and positional after posts: communityName, memberCount, total (the community's OWN post count when the read returned fewer than exist — the head then reads '20 of 87 posts' rather than stating only what it drew). Pass every post the read returned, up to twenty; three rows over a busy community is not the community. Use SkoolPostPreview when ONE post is the object; this is the list.",
props: z.object({
posts: z.array(z.object({
title: z.string(),
slug: z.string().nullish(),
excerpt: z.string().nullish(),
authorName: z.string().nullish(),
authorAvatarUrl: z.string().nullish(),
labelName: z.string().nullish(),
postedAt: z.string().nullish(),
likesCount: z.number().nullish(),
commentsCount: z.number().nullish(),
pinned: z.boolean().nullish(),
images: z.array(z.object({ url: z.string(), alt: z.string().nullish() })).nullish(),
})),
communityName: z.string().nullish(),
memberCount: z.number().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolFeedView
posts={(props.posts ?? []).map((p) => ({
title: p.title,
slug: p.slug ?? undefined,
excerpt: p.excerpt ?? undefined,
authorName: p.authorName ?? undefined,
authorAvatarUrl: p.authorAvatarUrl ?? undefined,
labelName: p.labelName ?? undefined,
postedAt: p.postedAt ?? undefined,
likesCount: p.likesCount ?? undefined,
commentsCount: p.commentsCount ?? undefined,
pinned: p.pinned ?? undefined,
images: (p.images ?? undefined)?.map((i) => ({ url: i.url, alt: i.alt ?? undefined })),
}))}
communityName={props.communityName ?? undefined}
memberCount={props.memberCount ?? undefined}
total={props.total ?? undefined}
/>
),
});
// ── SkoolCommentThread ──────────────────────────────────────────────────────
export interface SkoolComment {
readonly author: string;
readonly text: string;
readonly likes?: number;
readonly postedAt?: string;
readonly avatarUrl?: string;
readonly replies?: readonly SkoolComment[];
}
export interface SkoolCommentThreadProps {
comments: readonly SkoolComment[];
postTitle?: string;
communityName?: string;
now?: number;
/** The owner's own profile ⟨2026-09-09 13:4x⟩. A comment HE wrote wears the
* photo he set once; everyone else keeps the photo the read carried. It
* reaches this view because `renderFace` spreads the whole payload and
* `snappy-faces/api.ts#withViewer` puts it there — a face that does not
* DECLARE the prop drops it silently, which is what every face but the feed
* was doing. */
viewer?: ViewerProfile | null;
}
function SkoolCommentRow(
{ c, now, nested, viewer }: { c: SkoolComment; now: number; nested?: boolean; viewer?: ViewerProfile | null },
): JSX.Element {
const when = shortAge(c.postedAt, now);
const likes = formatSocialCount(c.likes);
const replies = (c.replies ?? []).filter((r) => r && typeof r.text === "string");
return (
<div className="sk-comment">
<PersonAvatar name={c.author} avatarUrl={c.avatarUrl} viewer={viewer} className={nested ? "sk-avatar sk-avatar--sm" : "sk-avatar"} />
<div className="sk-comment-main">
<div className="sk-comment-bubble">
<div className="sk-comment-name">{c.author}</div>
<div className="sk-comment-text">{c.text}</div>
</div>
<div className="sk-comment-foot">
{when ? <span>{when}</span> : null}
<span>{likes ? `${likes} likes` : "Like"}</span>
<span>Reply</span>
</div>
{replies.length > 0 ? (
<div className="sk-comment-replies">
{replies.map((r, i) => <SkoolCommentRow c={r} now={now} nested viewer={viewer} key={`${i}:${r.author}`} />)}
</div>
) : null}
</div>
</div>
);
}
export function SkoolCommentThreadView(props: SkoolCommentThreadProps): JSX.Element {
const now = props.now ?? Date.now();
const rows = (props.comments ?? []).filter(
(c) => c && typeof c.text === "string" && c.text.trim() !== "",
);
// HOW MANY, COUNTING REPLIES ⟨the owner, 2026-09-09 01:5x: "you see 20 emails
// pop up in front of you, not three"⟩. A thread drawn with no count leaves a
// person unable to tell a whole conversation from the top of one.
const total = rows.reduce((n, c) => n + 1 + (c.replies ?? []).length, 0);
return (
<div className="chat-card-enter dest-preview-root sk-root" data-channel="skool-comment-thread" data-count={total}>
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">{props.postTitle ?? "Comments"}</span>
{props.communityName ? <span className="sk-head-sub">{props.communityName}</span> : null}
{total === 0 ? null : <span className="sk-head-sub">{total} {total === 1 ? "comment" : "comments"}</span>}
</div>
<div className="sk-comments">
{rows.length === 0
? <p className="sk-feed-excerpt">No comments were returned for this post.</p>
: rows.map((c, i) => <SkoolCommentRow c={c} now={now} viewer={props.viewer} key={`${i}:${c.author}`} />)}
</div>
</div>
);
}
export const SkoolCommentThreadComponent = defineComponent({
name: "SkoolCommentThread",
description:
"USE FOR: 'what did people say on that post', 'show me the Skool comments', 'who needs a reply in the community'. The comment thread under a Skool post: bubbles with the author's name, the comment, its age and like count, and replies indented under their parent. Compact call: SkoolCommentThread(comments) where comments is an array of {author, text} and optionally {likes, postedAt, avatarUrl, replies}. replies is the same shape, one level deep — which is what Skool draws. Optional and positional after comments: postTitle, communityName. An empty array draws the honest 'no comments were returned', never an invented one.",
props: z.object({
comments: z.array(z.object({
author: z.string(),
text: z.string(),
likes: z.number().nullish(),
postedAt: z.string().nullish(),
avatarUrl: z.string().nullish(),
replies: z.array(z.object({
author: z.string(),
text: z.string(),
likes: z.number().nullish(),
postedAt: z.string().nullish(),
avatarUrl: z.string().nullish(),
})).nullish(),
})),
postTitle: z.string().nullish(),
communityName: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolCommentThreadView
comments={(props.comments ?? []).map((c) => ({
author: c.author,
text: c.text,
likes: c.likes ?? undefined,
postedAt: c.postedAt ?? undefined,
avatarUrl: c.avatarUrl ?? undefined,
replies: (c.replies ?? undefined)?.map((r) => ({
author: r.author,
text: r.text,
likes: r.likes ?? undefined,
postedAt: r.postedAt ?? undefined,
avatarUrl: r.avatarUrl ?? undefined,
})),
}))}
postTitle={props.postTitle ?? undefined}
communityName={props.communityName ?? undefined}
/>
),
});
// ── SkoolClassroomModule ────────────────────────────────────────────────────
export interface SkoolLesson {
readonly title: string;
readonly durationWords?: string;
readonly completed?: boolean;
}
export interface SkoolClassroomModuleProps {
title: string;
lessons: readonly SkoolLesson[];
description?: string;
coverUrl?: string;
/** How many lessons a member has finished. A REAL number or absent — absent
* draws no progress bar at all, because a zero-width bar is a measurement. */
completedCount?: number;
}
export function SkoolClassroomModuleView(props: SkoolClassroomModuleProps): JSX.Element {
const lessons = (props.lessons ?? []).filter(
(l) => l && typeof l.title === "string" && l.title.trim() !== "",
);
const cover = typeof props.coverUrl === "string" && /^(https?:|data:)/i.test(props.coverUrl.trim());
const known = typeof props.completedCount === "number"
&& Number.isFinite(props.completedCount)
&& lessons.length > 0;
const done = known ? Math.max(0, Math.min(lessons.length, Math.round(props.completedCount!))) : 0;
const pct = known ? Math.round((done / lessons.length) * 100) : 0;
return (
<div className="chat-card-enter dest-preview-root sk-root sk-module-root" data-channel="skool-classroom-module">
<div className="sk-head">
<BrandMark domain={SKOOL} fallback="Skool" size="xs" />
<span className="sk-head-name">Classroom</span>
<span className="sk-head-sub">{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
</div>
<div className="sk-module-cover">
{cover ? <img src={props.coverUrl} alt="" /> : <span>No module cover was made yet.</span>}
</div>
<div className="sk-module-body">
<h4 className="sk-module-title">{props.title}</h4>
{props.description ? <p className="sk-module-about">{props.description}</p> : null}
{known ? (
<>
<div className="sk-progress"><div className="sk-progress-fill" style={{ width: `${pct}%` }} /></div>
<div className="sk-progress-words">{done} of {lessons.length} complete</div>
</>
) : null}
{lessons.length === 0 ? (
<p className="sk-module-about">This module has no lessons yet.</p>
) : (
<ul className="sk-lessons">
{lessons.map((l, i) => (
<li className="sk-lesson" key={`${i}:${l.title}`}>
<span className="sk-lesson-tick" data-done={l.completed ? "true" : "false"} aria-hidden="true">
{l.completed ? "✓" : ""}
</span>
<span className="sk-lesson-name">{l.title}</span>
{l.durationWords ? <span className="sk-lesson-len">{l.durationWords}</span> : null}
</li>
))}
</ul>
)}
</div>
</div>
);
}
export const SkoolClassroomModuleComponent = defineComponent({
name: "SkoolClassroomModule",
description:
"USE FOR: 'the classroom module', 'outline the course', 'what lessons are in the module', 'plan the Skool course'. One Skool classroom module drawn as Skool draws it: the 16:9 module cover, the module title and blurb, the completion bar, and the lesson list with its ticks. Compact call: SkoolClassroomModule(title, lessons) where lessons is an array of {title, durationWords?, completed?} — durationWords is words a person reads ('8 min'), not a number. Optional and positional after lessons: description, coverUrl (omit and the face says no cover was made rather than drawing an empty box), completedCount (a REAL count of finished lessons; omit it and NO progress bar is drawn at all, because a zero-width bar is a measurement nobody took).",
props: z.object({
title: z.string(),
lessons: z.array(z.object({
title: z.string(),
durationWords: z.string().nullish(),
completed: z.boolean().nullish(),
})),
description: z.string().nullish(),
coverUrl: z.string().nullish(),
completedCount: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolClassroomModuleView
title={props.title}
lessons={(props.lessons ?? []).map((l) => ({
title: l.title,
durationWords: l.durationWords ?? undefined,
completed: l.completed ?? undefined,
}))}
description={props.description ?? undefined}
coverUrl={props.coverUrl ?? undefined}
completedCount={props.completedCount ?? undefined}
/>
),
});
/**
* skool-post-preview.tsx — A STAGED SKOOL POST LOOKS LIKE THE SKOOL
* COMPOSER.
*
* Robert, ratified 2026-08-07 (voice): "Skool post looks like the Skool
* composer." Built against CAPTURED BYTES, not an idea of them: the real staged
* skool write in `surfaces/__wire__/provider-approvals.json` (providerId
* `skool-community_create_post`) carries exactly `title`, `content` and an
* opaque `label_id`. So the face requires a title and a body — a Skool post IS
* a titled post — and it never renders `label_id`: an opaque id dressed as a
* category pill would be a fact this card made up. `labelName` exists for the
* day the record carries the label's own words.
*
* NOTHING IS INVENTED TO FILL THE FACE. No author staged → quiet disc, no
* glyph. No community named → no community line. Engagement counts belong to a
* post that has LIVED; a draft has none and prints none.
*/
import { type JSX, type ReactNode } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { Media, type MediaItem } from "../../../snappy-faces/library/src/components/post-media.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import { InPlaceText, type FaceSlotEdit } from "../../../snappy-faces/library/src/components/face-edit";
import { ManagedFrom, type ManagedSurfaceKind } from "../../../snappy-faces/library/src/components/managed-from";
import { formatSocialCount } from "../../../snappy-faces/library/src/components/social-card-format";
// THE OPERATOR'S IDENTITY ARRIVES, IT IS NEVER FETCHED ⟨the move, 2026-09-07⟩.
// This file read the app's `useOperatorIdentity()` — a daemon read — until it
// moved into the library, where that import is exactly what the package header
// bans. The app publishes the same value through the context this hook reads;
// with no host, the hook answers the empty identity and the face draws the
// quiet unglyphed disc, which is what an install with nothing saved already
// looked like. See `operator-identity-context.ts` for the whole argument.
import { useFaceOperatorIdentity } from "../../../snappy-faces/library/src/components/operator-identity-context";
import { deriveSenderAvatar } from "../../../snappy-faces/library/src/components/sender-avatar";
import "../../../snappy-faces/library/src/components/destination-previews.css";
export interface SkoolPostPreviewProps {
title: string;
body: string;
/** THE OWNER'S OWN PROFILE ⟨2026-09-09 13:4x⟩. A post HE staged wears the
* photo he set once in the bar's Account tab. It arrives on the payload from
* `snappy-faces/api.ts#withViewer` and is spread here by `renderFace`; a
* face that does not DECLARE the prop drops it in silence, which is what
* every Skool face but the feed was doing. It never overrides a photo the
* read fetched — `person.tsx` decides that, once, for all of them. */
viewer?: ViewerProfile | null;
/** THE EDITING SEAM (Robert, 2026-08-07: "click in and edit the content").
* A Skool post is a titled post, so BOTH slots may carry one — each only
* when the record's own revision road declared its backing field editable,
* which the miniature registry proves before passing these. Absent, the
* face renders exactly as it always did; chat/GenUI carry no seam. */
titleEdit?: FaceSlotEdit;
bodyEdit?: FaceSlotEdit;
/** THE PICTURES ON THE POST ⟨the owner, 2026-09-09 13:4x⟩. The hand carries
* them now: `post-detail` reads `metadata.attachmentsData` — Skool's own JSON
* string of every attachment with its `read_url` and the uploader's
* `file_name` as the alt — and the feed reads `metadata.imagePreview`, the
* one picture a feed page holds ⟨skills/snappy-skool/api.ts skoolImagesOf,
* measured 2026-09-09⟩. Absent still draws NOTHING, never a placeholder
* frame. */
images?: readonly MediaItem[];
/** THE EMPTY-STATE ARM (Robert, 2026-08-07, on the create-automation card:
* approving a Skool-posting errand you SEE the Skool composer frame it will
* stage into). Set, the frame is a PROMISE: composer anatomy, these words in
* the body slot, a "Nothing here yet" pill, NO title (a placeholder title
* would be a fact this card made up) and no managed-from line. Pass title
* and body as "" — the promise arm draws neither. */
promise?: string;
/** The community it lands in, when the record names one — "Focus & Ship". */
communityName?: string;
/** Display name, or absent when the record staged none (the account posts —
* THE FOUNDER'S RULING, 2026-08-08: "I would want my image from my
* settings to be used as default." An absent authorName defaults to the
* operator's own saved identity, same law as LinkedIn's face, and stays
* the quiet unglyphed disc only when settings has no name saved either. */
authorName?: string;
/** The account's own avatar, when the record carries one. */
authorAvatarUrl?: string;
/** The category label's own words — never an id. */
labelName?: string;
/** ISO timestamp of a post that HAPPENED; absent on a draft. */
postedAt?: string;
likesCount?: number;
commentsCount?: number;
managedFrom?: ManagedSurfaceKind;
/** THE ACT'S OWN WORD ON THE PILL, when the caller knows what the record's
* operation actually does (`state/lib/face-verb.ts`, reached through
* `surfaces/parts/face-order.ts#actPillWords`). Absent keeps this face's
* standing word. View-level and deliberately NOT in the model schema: a Lang
* program may not relabel a pill over work it did not stage. */
pillWords?: string;
/** THE POST CARRIES ITS OWN DECISION ⟨the founder, 2026-09-03: "all this crap
* down here saying send or keep or whatever could literally be ONE beautiful
* component"; connected for Skool 2026-09-07⟩.
*
* `channel-artifact-face.tsx` used to say "SKOOL DRAWS NO DOORS", and gave
* the true reason: this face declared no decision slot, and minting an action
* row THERE would have been the second door vocabulary `channel-decision.tsx`
* bans in its own header. So a staged Skool post drew its words and sent the
* person somewhere else to decide on them.
*
* It is a NODE, already mounted, and that is what makes this ONE matcher
* rather than two. The row is built by the app's `useChannelDecision` — the
* one fold over the one `Door` record set, whose reply spelling comes from
* `convex/run_signoff.ts` — and this package may not import any of that
* (CLAUDE.md: nothing here reads the daemon or the store). So the HOST folds
* and hands down the finished row; the face only says where it sits. LinkedIn
* and Gmail call that hook inside themselves because they still live in the
* app; when they move here they take this same shape. */
decisionRow?: ReactNode;
}
export function SkoolPostPreviewView(props: SkoolPostPreviewProps): JSX.Element {
// THE ONE SENDER AVATAR DERIVATION (Robert's law, 2026-08-08).
const identity = useFaceOperatorIdentity();
const avatar = deriveSenderAvatar(
props.authorName ?? "",
"", // Skool posts have no email field
props.authorAvatarUrl,
identity,
);
const promise = props.promise?.trim() || undefined;
const posted = typeof props.postedAt === "string" && !Number.isNaN(Date.parse(props.postedAt));
const counts = [
{ n: formatSocialCount(props.likesCount), word: "likes" },
{ n: formatSocialCount(props.commentsCount), word: "comments" },
].filter((c) => c.n.length > 0);
return (
<div
className="chat-card-enter dest-preview-root skool-post-root"
data-channel="skool-post-preview"
{...(promise === undefined ? {} : { "data-promise": "true" })}
>
{/* The brand mark IS the recognition anchor — the app's ONE mark component. */}
<span className="dest-preview-source" aria-hidden="true">
{/* THE REAL LOGO, NOT A LETTER (2026-09-02): the rail draws this same domain through this same mark and gets the brand; with no domain the mark falls back to an initial, which is what a LinkedIn post wore here. */}
<BrandMark domain="skool.com" fallback="Skool" size="xs" />
Skool
</span>
<div className="skool-post-head">
<PersonAvatar
name={avatar.name}
avatarUrl={avatar.avatarUrl}
me={avatar.absent ? true : undefined}
viewer={props.viewer}
className={avatar.absent ? "skool-post-avatar dest-preview-avatar--absent" : "skool-post-avatar"}
/>
<div className="skool-post-who">
{avatar.absent ? null : <div className="skool-post-name">{avatar.name}</div>}
<div className="skool-post-where">
{props.communityName ? <b>{props.communityName}</b> : null}
{props.communityName && props.labelName ? " · " : null}
{props.labelName ?? null}
</div>
</div>
{posted ? null : (
<span className="dest-preview-pill">
{promise === undefined ? (props.pillWords ?? "Draft") : "Nothing here yet"}
</span>
)}
</div>
{promise === undefined
? (
<h3 className="skool-post-title">
{props.titleEdit === undefined ? props.title : <InPlaceText edit={props.titleEdit} />}
</h3>
)
: null}
{promise === undefined
? (
<div className="skool-post-body dest-preview-body">
{props.bodyEdit === undefined ? props.body : <InPlaceText edit={props.bodyEdit} multiline />}
<Media items={props.images} />
</div>
)
: <div className="skool-post-body dest-preview-body dest-preview-body--promise">{promise}</div>}
{counts.length > 0 ? (
<div className="skool-post-counts">
{counts.map((c) => <span key={c.word}>{c.n} {c.word}</span>)}
</div>
) : null}
{/* THE DOORS SIT INSIDE THE POST, above the managed-from line: the row is
what a person acts on, and the line below it says where the decision
lives if they leave. A promise frame draws neither — there is nothing
staged yet to decide. */}
{promise === undefined ? props.decisionRow ?? null : null}
{promise === undefined
? <ManagedFrom kind={props.managedFrom ?? (posted ? "sent-record" : "staged-write")} />
: null}
</div>
);
}
export const SkoolPostPreviewComponent = defineComponent({
name: "SkoolPostPreview",
description:
"USE FOR: 'draft a Skool post', 'post this to the community', 'show me the Skool post'. Channel-faithful preview of a Skool community post — titled, in the composer's own anatomy, both app themes. Compact call: SkoolPostPreview(title, body). A Skool post always has a title; body carries the full text with its line breaks. Optional and positional after body: communityName (the community's own name), authorName (omit or pass '' when no author was staged — the connected account posts it), labelName (the category's WORDS, never an id), postedAt (ISO, only for a post that already happened — a draft omits it and wears the Draft pill), likesCount, commentsCount. Counts render only when given.",
props: z.object({
title: z.string(),
body: z.string(),
communityName: z.string().nullish(),
authorName: z.string().nullish(),
labelName: z.string().nullish(),
postedAt: z.string().nullish(),
likesCount: z.number().nullish(),
commentsCount: z.number().nullish(),
// THE TWO THE SCHEMA DROPPED ⟨2026-09-09 14:2x⟩. The view has declared
// `authorAvatarUrl` and `images` since the Person and Media primitives
// landed, and the props this component publishes did not — so a read could
// carry a photo and a photograph and neither could cross into the face. A
// slot on the view that the schema does not declare is a slot no caller can
// ever reach.
authorAvatarUrl: z.string().nullish(),
images: z.array(z.object({ url: z.string(), alt: z.string().nullish() })).nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolPostPreviewView
title={props.title}
body={props.body}
communityName={props.communityName ?? undefined}
authorName={props.authorName ?? undefined}
authorAvatarUrl={props.authorAvatarUrl ?? undefined}
images={(props.images ?? undefined)?.map((i) => ({ url: i.url, alt: i.alt ?? undefined }))}
labelName={props.labelName ?? undefined}
postedAt={props.postedAt ?? undefined}
likesCount={props.likesCount ?? undefined}
commentsCount={props.commentsCount ?? undefined}
/>
),
});
/**
* skool-post-preview.tsx — A STAGED SKOOL POST LOOKS LIKE THE SKOOL
* COMPOSER.
*
* Robert, ratified 2026-08-07 (voice): "Skool post looks like the Skool
* composer." Built against CAPTURED BYTES, not an idea of them: the real staged
* skool write in `surfaces/__wire__/provider-approvals.json` (providerId
* `skool-community_create_post`) carries exactly `title`, `content` and an
* opaque `label_id`. So the face requires a title and a body — a Skool post IS
* a titled post — and it never renders `label_id`: an opaque id dressed as a
* category pill would be a fact this card made up. `labelName` exists for the
* day the record carries the label's own words.
*
* NOTHING IS INVENTED TO FILL THE FACE. No author staged → quiet disc, no
* glyph. No community named → no community line. Engagement counts belong to a
* post that has LIVED; a draft has none and prints none.
*/
import { type JSX, type ReactNode } from "react";
import { z } from "zod/v4";
import { defineComponent } from "@openuidev/react-lang";
import { PersonAvatar, type ViewerProfile } from "../../../snappy-faces/library/src/components/person.tsx";
import { Media, type MediaItem } from "../../../snappy-faces/library/src/components/post-media.tsx";
import { BrandMark } from "../../../snappy-faces/library/src/components/domain-logos";
import { InPlaceText, type FaceSlotEdit } from "../../../snappy-faces/library/src/components/face-edit";
import { ManagedFrom, type ManagedSurfaceKind } from "../../../snappy-faces/library/src/components/managed-from";
import { formatSocialCount } from "../../../snappy-faces/library/src/components/social-card-format";
// THE OPERATOR'S IDENTITY ARRIVES, IT IS NEVER FETCHED ⟨the move, 2026-09-07⟩.
// This file read the app's `useOperatorIdentity()` — a daemon read — until it
// moved into the library, where that import is exactly what the package header
// bans. The app publishes the same value through the context this hook reads;
// with no host, the hook answers the empty identity and the face draws the
// quiet unglyphed disc, which is what an install with nothing saved already
// looked like. See `operator-identity-context.ts` for the whole argument.
import { useFaceOperatorIdentity } from "../../../snappy-faces/library/src/components/operator-identity-context";
import { deriveSenderAvatar } from "../../../snappy-faces/library/src/components/sender-avatar";
import "../../../snappy-faces/library/src/components/destination-previews.css";
export interface SkoolPostPreviewProps {
title: string;
body: string;
/** THE OWNER'S OWN PROFILE ⟨2026-09-09 13:4x⟩. A post HE staged wears the
* photo he set once in the bar's Account tab. It arrives on the payload from
* `snappy-faces/api.ts#withViewer` and is spread here by `renderFace`; a
* face that does not DECLARE the prop drops it in silence, which is what
* every Skool face but the feed was doing. It never overrides a photo the
* read fetched — `person.tsx` decides that, once, for all of them. */
viewer?: ViewerProfile | null;
/** THE EDITING SEAM (Robert, 2026-08-07: "click in and edit the content").
* A Skool post is a titled post, so BOTH slots may carry one — each only
* when the record's own revision road declared its backing field editable,
* which the miniature registry proves before passing these. Absent, the
* face renders exactly as it always did; chat/GenUI carry no seam. */
titleEdit?: FaceSlotEdit;
bodyEdit?: FaceSlotEdit;
/** THE PICTURES ON THE POST ⟨the owner, 2026-09-09 13:4x⟩. The hand carries
* them now: `post-detail` reads `metadata.attachmentsData` — Skool's own JSON
* string of every attachment with its `read_url` and the uploader's
* `file_name` as the alt — and the feed reads `metadata.imagePreview`, the
* one picture a feed page holds ⟨skills/snappy-skool/api.ts skoolImagesOf,
* measured 2026-09-09⟩. Absent still draws NOTHING, never a placeholder
* frame. */
images?: readonly MediaItem[];
/** THE EMPTY-STATE ARM (Robert, 2026-08-07, on the create-automation card:
* approving a Skool-posting errand you SEE the Skool composer frame it will
* stage into). Set, the frame is a PROMISE: composer anatomy, these words in
* the body slot, a "Nothing here yet" pill, NO title (a placeholder title
* would be a fact this card made up) and no managed-from line. Pass title
* and body as "" — the promise arm draws neither. */
promise?: string;
/** The community it lands in, when the record names one — "Focus & Ship". */
communityName?: string;
/** Display name, or absent when the record staged none (the account posts —
* THE FOUNDER'S RULING, 2026-08-08: "I would want my image from my
* settings to be used as default." An absent authorName defaults to the
* operator's own saved identity, same law as LinkedIn's face, and stays
* the quiet unglyphed disc only when settings has no name saved either. */
authorName?: string;
/** The account's own avatar, when the record carries one. */
authorAvatarUrl?: string;
/** The category label's own words — never an id. */
labelName?: string;
/** ISO timestamp of a post that HAPPENED; absent on a draft. */
postedAt?: string;
likesCount?: number;
commentsCount?: number;
managedFrom?: ManagedSurfaceKind;
/** THE ACT'S OWN WORD ON THE PILL, when the caller knows what the record's
* operation actually does (`state/lib/face-verb.ts`, reached through
* `surfaces/parts/face-order.ts#actPillWords`). Absent keeps this face's
* standing word. View-level and deliberately NOT in the model schema: a Lang
* program may not relabel a pill over work it did not stage. */
pillWords?: string;
/** THE POST CARRIES ITS OWN DECISION ⟨the founder, 2026-09-03: "all this crap
* down here saying send or keep or whatever could literally be ONE beautiful
* component"; connected for Skool 2026-09-07⟩.
*
* `channel-artifact-face.tsx` used to say "SKOOL DRAWS NO DOORS", and gave
* the true reason: this face declared no decision slot, and minting an action
* row THERE would have been the second door vocabulary `channel-decision.tsx`
* bans in its own header. So a staged Skool post drew its words and sent the
* person somewhere else to decide on them.
*
* It is a NODE, already mounted, and that is what makes this ONE matcher
* rather than two. The row is built by the app's `useChannelDecision` — the
* one fold over the one `Door` record set, whose reply spelling comes from
* `convex/run_signoff.ts` — and this package may not import any of that
* (CLAUDE.md: nothing here reads the daemon or the store). So the HOST folds
* and hands down the finished row; the face only says where it sits. LinkedIn
* and Gmail call that hook inside themselves because they still live in the
* app; when they move here they take this same shape. */
decisionRow?: ReactNode;
}
export function SkoolPostPreviewView(props: SkoolPostPreviewProps): JSX.Element {
// THE ONE SENDER AVATAR DERIVATION (Robert's law, 2026-08-08).
const identity = useFaceOperatorIdentity();
const avatar = deriveSenderAvatar(
props.authorName ?? "",
"", // Skool posts have no email field
props.authorAvatarUrl,
identity,
);
const promise = props.promise?.trim() || undefined;
const posted = typeof props.postedAt === "string" && !Number.isNaN(Date.parse(props.postedAt));
const counts = [
{ n: formatSocialCount(props.likesCount), word: "likes" },
{ n: formatSocialCount(props.commentsCount), word: "comments" },
].filter((c) => c.n.length > 0);
return (
<div
className="chat-card-enter dest-preview-root skool-post-root"
data-channel="skool-post-preview"
{...(promise === undefined ? {} : { "data-promise": "true" })}
>
{/* The brand mark IS the recognition anchor — the app's ONE mark component. */}
<span className="dest-preview-source" aria-hidden="true">
{/* THE REAL LOGO, NOT A LETTER (2026-09-02): the rail draws this same domain through this same mark and gets the brand; with no domain the mark falls back to an initial, which is what a LinkedIn post wore here. */}
<BrandMark domain="skool.com" fallback="Skool" size="xs" />
Skool
</span>
<div className="skool-post-head">
<PersonAvatar
name={avatar.name}
avatarUrl={avatar.avatarUrl}
me={avatar.absent ? true : undefined}
viewer={props.viewer}
className={avatar.absent ? "skool-post-avatar dest-preview-avatar--absent" : "skool-post-avatar"}
/>
<div className="skool-post-who">
{avatar.absent ? null : <div className="skool-post-name">{avatar.name}</div>}
<div className="skool-post-where">
{props.communityName ? <b>{props.communityName}</b> : null}
{props.communityName && props.labelName ? " · " : null}
{props.labelName ?? null}
</div>
</div>
{posted ? null : (
<span className="dest-preview-pill">
{promise === undefined ? (props.pillWords ?? "Draft") : "Nothing here yet"}
</span>
)}
</div>
{promise === undefined
? (
<h3 className="skool-post-title">
{props.titleEdit === undefined ? props.title : <InPlaceText edit={props.titleEdit} />}
</h3>
)
: null}
{promise === undefined
? (
<div className="skool-post-body dest-preview-body">
{props.bodyEdit === undefined ? props.body : <InPlaceText edit={props.bodyEdit} multiline />}
<Media items={props.images} />
</div>
)
: <div className="skool-post-body dest-preview-body dest-preview-body--promise">{promise}</div>}
{counts.length > 0 ? (
<div className="skool-post-counts">
{counts.map((c) => <span key={c.word}>{c.n} {c.word}</span>)}
</div>
) : null}
{/* THE DOORS SIT INSIDE THE POST, above the managed-from line: the row is
what a person acts on, and the line below it says where the decision
lives if they leave. A promise frame draws neither — there is nothing
staged yet to decide. */}
{promise === undefined ? props.decisionRow ?? null : null}
{promise === undefined
? <ManagedFrom kind={props.managedFrom ?? (posted ? "sent-record" : "staged-write")} />
: null}
</div>
);
}
export const SkoolPostPreviewComponent = defineComponent({
name: "SkoolPostPreview",
description:
"USE FOR: 'draft a Skool post', 'post this to the community', 'show me the Skool post'. Channel-faithful preview of a Skool community post — titled, in the composer's own anatomy, both app themes. Compact call: SkoolPostPreview(title, body). A Skool post always has a title; body carries the full text with its line breaks. Optional and positional after body: communityName (the community's own name), authorName (omit or pass '' when no author was staged — the connected account posts it), labelName (the category's WORDS, never an id), postedAt (ISO, only for a post that already happened — a draft omits it and wears the Draft pill), likesCount, commentsCount. Counts render only when given.",
props: z.object({
title: z.string(),
body: z.string(),
communityName: z.string().nullish(),
authorName: z.string().nullish(),
labelName: z.string().nullish(),
postedAt: z.string().nullish(),
likesCount: z.number().nullish(),
commentsCount: z.number().nullish(),
// THE TWO THE SCHEMA DROPPED ⟨2026-09-09 14:2x⟩. The view has declared
// `authorAvatarUrl` and `images` since the Person and Media primitives
// landed, and the props this component publishes did not — so a read could
// carry a photo and a photograph and neither could cross into the face. A
// slot on the view that the schema does not declare is a slot no caller can
// ever reach.
authorAvatarUrl: z.string().nullish(),
images: z.array(z.object({ url: z.string(), alt: z.string().nullish() })).nullish(),
}),
component: ({ props }): JSX.Element => (
<SkoolPostPreviewView
title={props.title}
body={props.body}
communityName={props.communityName ?? undefined}
authorName={props.authorName ?? undefined}
authorAvatarUrl={props.authorAvatarUrl ?? undefined}
images={(props.images ?? undefined)?.map((i) => ({ url: i.url, alt: i.alt ?? undefined }))}
labelName={props.labelName ?? undefined}
postedAt={props.postedAt ?? undefined}
likesCount={props.likesCount ?? undefined}
commentsCount={props.commentsCount ?? undefined}
/>
),
});
/** families/skool.tsx — THE SKOOL FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/skool.js` the first time a skool face is
* drawn, and never before ⟨`face-family.ts`, why the widget is no longer one
* file⟩. Every mount below forwards the payload to the view unchanged — the
* same one `createElement` the core applies to all of them, so a per-face arm
* here would restate a forwarding that already exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { SkoolClassroomModuleView, SkoolCommentThreadView, SkoolFeedView } from "./components/skool-faces.tsx";
import { SkoolPostPreviewView } from "./components/skool-post-preview.tsx";
import { SkoolCommentComposerView } from "./components/skool-comment-composer.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "skool",
mounts: {
"skool-feed": SkoolFeedView,
"skool-post": SkoolPostPreviewView,
"skool-comments": SkoolCommentThreadView,
"skool-classroom": SkoolClassroomModuleView,
// THE COMPOSER DRAWS ITS OWN `DoorRow`, so the core hands it the same doors
// and the same press instead of a second row under the card.
"skool-comment": SkoolCommentComposerView,
},
ownsItsDoors: ["skool-comment"],
};
/** families/skool.tsx — THE SKOOL FAMILY, as its own chunk.
*
* Fetched from `ui://snappy/faces/skool.js` the first time a skool face is
* drawn, and never before ⟨`face-family.ts`, why the widget is no longer one
* file⟩. Every mount below forwards the payload to the view unchanged — the
* same one `createElement` the core applies to all of them, so a per-face arm
* here would restate a forwarding that already exists beside the component. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import { SkoolClassroomModuleView, SkoolCommentThreadView, SkoolFeedView } from "./components/skool-faces.tsx";
import { SkoolPostPreviewView } from "./components/skool-post-preview.tsx";
import { SkoolCommentComposerView } from "./components/skool-comment-composer.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "skool",
mounts: {
"skool-feed": SkoolFeedView,
"skool-post": SkoolPostPreviewView,
"skool-comments": SkoolCommentThreadView,
"skool-classroom": SkoolClassroomModuleView,
// THE COMPOSER DRAWS ITS OWN `DoorRow`, so the core hands it the same doors
// and the same press instead of a second row under the card.
"skool-comment": SkoolCommentComposerView,
},
ownsItsDoors: ["skool-comment"],
};
{
"title": "Module 2 - Jigs that hold",
"lessons": [
{
"title": "Why most shop jigs rack under load",
"durationWords": "6 min",
"completed": true
},
{
"title": "Choosing the clamp point",
"durationWords": "11 min",
"completed": true
},
{
"title": "Cutting the base square",
"durationWords": "14 min",
"completed": true
},
{
"title": "Testing it under a real cut",
"durationWords": "9 min"
},
{
"title": "The cut list, and the two mistakes to avoid",
"durationWords": "8 min"
}
],
"description": "Five lessons on building a crosscut jig that stays square when it matters.",
"coverUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0ODAiIGhlaWdodD0iMzAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImMiIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM0YThjN2QiLz48c3RvcCBvZmZzZXQ9IjAuNiIgc3RvcC1jb2xvcj0iIzJmNGE1YSIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzFiMWYyNiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI0ODAiIGhlaWdodD0iMzAwIiBmaWxsPSJ1cmwoI2MpIi8+PHJlY3QgeD0iNTQiIHk9IjExMCIgd2lkdGg9IjE0NCIgaGVpZ2h0PSI5NiIgcng9IjgiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xNCIvPjxyZWN0IHg9IjIyOCIgeT0iNzIiIHdpZHRoPSIxOTgiIGhlaWdodD0iMTM0IiByeD0iOCIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz48L3N2Zz4=",
"completedCount": 3
}
{
"title": "Module 2 - Jigs that hold",
"lessons": [
{
"title": "Why most shop jigs rack under load",
"durationWords": "6 min",
"completed": true
},
{
"title": "Choosing the clamp point",
"durationWords": "11 min",
"completed": true
},
{
"title": "Cutting the base square",
"durationWords": "14 min",
"completed": true
},
{
"title": "Testing it under a real cut",
"durationWords": "9 min"
},
{
"title": "The cut list, and the two mistakes to avoid",
"durationWords": "8 min"
}
],
"description": "Five lessons on building a crosscut jig that stays square when it matters.",
"coverUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0ODAiIGhlaWdodD0iMzAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImMiIHgxPSIwIiB5MT0iMCIgeDI9IjEiIHkyPSIxIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiM0YThjN2QiLz48c3RvcCBvZmZzZXQ9IjAuNiIgc3RvcC1jb2xvcj0iIzJmNGE1YSIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzFiMWYyNiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHdpZHRoPSI0ODAiIGhlaWdodD0iMzAwIiBmaWxsPSJ1cmwoI2MpIi8+PHJlY3QgeD0iNTQiIHk9IjExMCIgd2lkdGg9IjE0NCIgaGVpZ2h0PSI5NiIgcng9IjgiIGZpbGw9IiNmZmZmZmYiIGZpbGwtb3BhY2l0eT0iMC4xNCIvPjxyZWN0IHg9IjIyOCIgeT0iNzIiIHdpZHRoPSIxOTgiIGhlaWdodD0iMTM0IiByeD0iOCIgZmlsbD0iI2ZmZmZmZiIgZmlsbC1vcGFjaXR5PSIwLjIiLz48L3N2Zz4=",
"completedCount": 3
}
{
"text": "Two inches inboard here as well - worth putting in the cut list so nobody has to find it the hard way.",
"author": "you",
"post": "Read this before you build your first jig",
"community": "Makers Room",
"doors": [
{
"id": "send",
"label": "Comment",
"verb": "approved",
"price": "posts the comment on Skool now",
"primary": true
},
{
"id": "later",
"label": "Later",
"verb": "snoozed",
"price": "keeps it staged; nothing leaves this machine"
}
],
"thread": [
{
"author": "Nadia Brandt",
"text": "This is the post I needed six months ago. The clamp-point reasoning is the part nobody explains.",
"likes": 22,
"postedAt": "2026-09-04T17:40:00Z",
"replies": [
{
"author": "Mara Quill",
"text": "Two inches from the fence. Further than that and the stock lifts on the back of the cut.",
"likes": 14,
"postedAt": "2026-09-04T18:02:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E"
},
{
"author": "Milo Fenwick",
"text": "Built it this morning from your cut list. Dead square first go.",
"likes": 8,
"postedAt": "2026-09-05T10:15:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"threadKind": "skool-comments",
"threadTotal": 3,
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20152%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.90%200.05%20152%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.90%200.05%20152%29%22%2F%3E%3C%2Fsvg%3E"
}
{
"text": "Two inches inboard here as well - worth putting in the cut list so nobody has to find it the hard way.",
"author": "you",
"post": "Read this before you build your first jig",
"community": "Makers Room",
"doors": [
{
"id": "send",
"label": "Comment",
"verb": "approved",
"price": "posts the comment on Skool now",
"primary": true
},
{
"id": "later",
"label": "Later",
"verb": "snoozed",
"price": "keeps it staged; nothing leaves this machine"
}
],
"thread": [
{
"author": "Nadia Brandt",
"text": "This is the post I needed six months ago. The clamp-point reasoning is the part nobody explains.",
"likes": 22,
"postedAt": "2026-09-04T17:40:00Z",
"replies": [
{
"author": "Mara Quill",
"text": "Two inches from the fence. Further than that and the stock lifts on the back of the cut.",
"likes": 14,
"postedAt": "2026-09-04T18:02:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E"
},
{
"author": "Milo Fenwick",
"text": "Built it this morning from your cut list. Dead square first go.",
"likes": 8,
"postedAt": "2026-09-05T10:15:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"threadKind": "skool-comments",
"threadTotal": 3,
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20152%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.90%200.05%20152%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.90%200.05%20152%29%22%2F%3E%3C%2Fsvg%3E"
}
{
"comments": [
{
"author": "Nadia Brandt",
"text": "This is the post I needed six months ago. The clamp-point reasoning is the part nobody explains.",
"likes": 22,
"postedAt": "2026-09-04T17:40:00Z",
"replies": [
{
"author": "Mara Quill",
"text": "Two inches from the fence. Further than that and the stock lifts on the back of the cut.",
"likes": 14,
"postedAt": "2026-09-04T18:02:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E"
},
{
"author": "Milo Fenwick",
"text": "Built it this morning from your cut list. Dead square first go.",
"likes": 8,
"postedAt": "2026-09-05T10:15:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"postTitle": "Read this before you build your first jig",
"communityName": "Makers Room"
}
{
"comments": [
{
"author": "Nadia Brandt",
"text": "This is the post I needed six months ago. The clamp-point reasoning is the part nobody explains.",
"likes": 22,
"postedAt": "2026-09-04T17:40:00Z",
"replies": [
{
"author": "Mara Quill",
"text": "Two inches from the fence. Further than that and the stock lifts on the back of the cut.",
"likes": 14,
"postedAt": "2026-09-04T18:02:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E"
},
{
"author": "Milo Fenwick",
"text": "Built it this morning from your cut list. Dead square first go.",
"likes": 8,
"postedAt": "2026-09-05T10:15:00Z",
"avatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"postTitle": "Read this before you build your first jig",
"communityName": "Makers Room"
}
{
"posts": [
{
"title": "Read this before you build your first jig",
"slug": "read-this-before-you-build-your-first-jig",
"excerpt": "Three attempts, and the only one that held came from moving a single clamp point. Here is the whole reasoning so you can skip the first two.",
"authorName": "Mara Quill",
"labelName": "Build logs",
"postedAt": "2026-09-04T16:20:00Z",
"likesCount": 47,
"commentsCount": 12,
"pinned": true,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%2085%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%2085%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20125%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%2085%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The jig on the bench, clamped at the point that finally held"
}
]
},
{
"title": "Weekly wins thread - week 36",
"slug": "weekly-wins-thread-week-36",
"excerpt": "Post what you finished. Photos welcome, excuses also welcome, we have all had a week.",
"authorName": "Nadia Brandt",
"labelName": "Community",
"postedAt": "2026-09-03T09:00:00Z",
"likesCount": 31,
"commentsCount": 58,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20350%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20350%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%2030%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20350%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A finished drawer front"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20350%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20350%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%2030%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20350%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A shop cart, half built"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20300%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20300%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20340%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20300%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A pile of offcuts sorted by width"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20152%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20152%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20192%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20152%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A hand plane on a windowsill"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20300%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20300%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20340%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20300%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A bench dog"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%2085%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%2085%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20125%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%2085%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A router sled"
}
]
},
{
"title": "Anyone sourcing Baltic birch under $60 a sheet?",
"slug": "anyone-sourcing-baltic-birch-under-60-a-sheet",
"excerpt": "My usual supplier went to $78 and I am trying to work out whether that is the market or just them.",
"authorName": "Milo Fenwick",
"labelName": "Questions",
"postedAt": "2026-09-02T14:12:00Z",
"likesCount": 9,
"commentsCount": 23,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"communityName": "Makers Room",
"memberCount": 2140
}
{
"posts": [
{
"title": "Read this before you build your first jig",
"slug": "read-this-before-you-build-your-first-jig",
"excerpt": "Three attempts, and the only one that held came from moving a single clamp point. Here is the whole reasoning so you can skip the first two.",
"authorName": "Mara Quill",
"labelName": "Build logs",
"postedAt": "2026-09-04T16:20:00Z",
"likesCount": 47,
"commentsCount": 12,
"pinned": true,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%2085%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%2085%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20125%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%2085%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The jig on the bench, clamped at the point that finally held"
}
]
},
{
"title": "Weekly wins thread - week 36",
"slug": "weekly-wins-thread-week-36",
"excerpt": "Post what you finished. Photos welcome, excuses also welcome, we have all had a week.",
"authorName": "Nadia Brandt",
"labelName": "Community",
"postedAt": "2026-09-03T09:00:00Z",
"likesCount": 31,
"commentsCount": 58,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20300%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20300%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20350%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20350%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%2030%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20350%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A finished drawer front"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20350%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20350%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%2030%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20350%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A shop cart, half built"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20300%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20300%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20340%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20300%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A pile of offcuts sorted by width"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20152%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20152%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20192%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20152%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A hand plane on a windowsill"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20300%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20300%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20340%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20300%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A bench dog"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%2085%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%2085%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20125%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%2085%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "A router sled"
}
]
},
{
"title": "Anyone sourcing Baltic birch under $60 a sheet?",
"slug": "anyone-sourcing-baltic-birch-under-60-a-sheet",
"excerpt": "My usual supplier went to $78 and I am trying to work out whether that is the market or just them.",
"authorName": "Milo Fenwick",
"labelName": "Questions",
"postedAt": "2026-09-02T14:12:00Z",
"likesCount": 9,
"commentsCount": 23,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%2085%29%22%2F%3E%3C%2Fsvg%3E"
}
],
"communityName": "Makers Room",
"memberCount": 2140
}
{
"title": "What I learned rebuilding my jig from scratch",
"body": "Third attempt and the first one that holds square under load. The trick was moving the clamp point two inches inboard - photos and the cut list are in the comments.",
"communityName": "Makers Room",
"authorName": "Mara Quill",
"labelName": "Build logs",
"postedAt": "2026-09-04T16:20:00Z",
"likesCount": 47,
"commentsCount": 12,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20255%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20255%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20295%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20255%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The clamp point, close up"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20195%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20195%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20235%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20195%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The same jig from above"
}
]
}
{
"title": "What I learned rebuilding my jig from scratch",
"body": "Third attempt and the first one that holds square under load. The trick was moving the clamp point two inches inboard - photos and the cut list are in the comments.",
"communityName": "Makers Room",
"authorName": "Mara Quill",
"labelName": "Build logs",
"postedAt": "2026-09-04T16:20:00Z",
"likesCount": 47,
"commentsCount": 12,
"authorAvatarUrl": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22oklch%280.58%200.16%20255%29%22%2F%3E%3Ccircle%20cx%3D%2232%22%20cy%3D%2225%22%20r%3D%2212%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3Cpath%20d%3D%22M8%2064c0-14%2011-22%2024-22s24%208%2024%2022z%22%20fill%3D%22oklch%280.86%200.05%20350%29%22%2F%3E%3C%2Fsvg%3E",
"images": [
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20255%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20255%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20295%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20255%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The clamp point, close up"
},
{
"url": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22oklch%280.72%200.09%20195%29%22%2F%3E%3Crect%20x%3D%220%22%20y%3D%22190%22%20width%3D%22400%22%20height%3D%22110%22%20fill%3D%22oklch%280.45%200.07%20195%29%22%2F%3E%3Ccircle%20cx%3D%22310%22%20cy%3D%2270%22%20r%3D%2234%22%20fill%3D%22oklch%280.93%200.06%20235%29%22%2F%3E%3Cpath%20d%3D%22M0%20210%20L110%20120%20L200%20210%20Z%22%20fill%3D%22oklch%280.55%200.08%20195%29%22%2F%3E%3C%2Fsvg%3E",
"alt": "The same jig from above"
}
]
}
/**
* COVERAGE FOR SNAPPY-SKOOL'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — the same row object, not
* a copy that can drift. The second is that every declared code is GROUNDED:
* the evidence that justified declaring it is re-checked here, because a
* refusal code with no path that emits it is a branch the reader waits for and
* never sees, and a table of those passes a lint while teaching a lie.
*
* SOURCE is this hand's OWN executable — api.ts and the modules beside it,
* never its tests and never another skill's file — which is exactly the text
* the codemod measured when it chose these rows. Grading against a different
* text than the one that decided is how the two drift.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const SOURCE = readdirSync(HERE)
.filter((f) => f.endsWith(".ts") && !/\.(test|spec)\.ts$/.test(f))
.sort()
.map((f) => readFileSync(join(HERE, f), "utf8"))
.join("\n");
/** Every refusal code snappy-skool declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
"upstream_error",
] as const;
test("snappy-skool declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length > 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
test("upstream_error is grounded: the hand has an outward road that can answer with its own failure", () => {
assert.ok(/\bfetch\(|from "\.\.\/snappy-[a-z-]+\/api\.ts"/.test(SOURCE),
"no fetch here and no delegate hand, so no provider can answer with a failure of its own");
});
/**
* COVERAGE FOR SNAPPY-SKOOL'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — the same row object, not
* a copy that can drift. The second is that every declared code is GROUNDED:
* the evidence that justified declaring it is re-checked here, because a
* refusal code with no path that emits it is a branch the reader waits for and
* never sees, and a table of those passes a lint while teaching a lie.
*
* SOURCE is this hand's OWN executable — api.ts and the modules beside it,
* never its tests and never another skill's file — which is exactly the text
* the codemod measured when it chose these rows. Grading against a different
* text than the one that decided is how the two drift.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const SOURCE = readdirSync(HERE)
.filter((f) => f.endsWith(".ts") && !/\.(test|spec)\.ts$/.test(f))
.sort()
.map((f) => readFileSync(join(HERE, f), "utf8"))
.join("\n");
/** Every refusal code snappy-skool declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
"upstream_error",
] as const;
test("snappy-skool declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length > 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
test("upstream_error is grounded: the hand has an outward road that can answer with its own failure", () => {
assert.ok(/\bfetch\(|from "\.\.\/snappy-[a-z-]+\/api\.ts"/.test(SOURCE),
"no fetch here and no delegate hand, so no provider can answer with a failure of its own");
});