← All Skills

snappy-channel-contract

v1.0.0
8 files, 39.1 KB ~1,798 words · 8 min read Updated 2026-09-09

snappy-channel-contract skill

47 of 49 checks pass
What it can do
certsread
listread
schemaread
validateread
What does not pass yet
$ npx snappy-skills install snappy-channel-contract
zip ↓
Documents
AGENTS.md

snappy-channel-contract — The Hands-and-Eyes Layer#

Every channel skill (snappy-slack, snappy-email, snappy-linkedin, snappy-whatsapp, snappy-imessage, snappy-telegram, snappy-skool) exports an adapter.ts conforming to this contract. Chains consume only the contract types — never the native api.ts shapes. That's what makes a chain channel-agnostic.

The three verbs#

typescriptimport type { ChannelAdapter, Event, PostTarget, PostContent } from "../snappy-channel-contract/types.ts";

const adapter: ChannelAdapter = {
  source: "slack",
  read(since?, limit?)    -> Promise<Event[]>
  post(target, content)   -> Promise<PostResult>
  identify(authorId)      -> Promise<Contact | null>
  selfCheck()             -> Promise<SelfCheckResult>
};

Event shape (canonical)#

typescript{
  source: "slack" | "gmail" | "linkedin" | ...,
  event_id: string,           // unique within source
  thread_id: string | null,   // null if channel has no threads
  channel_id: string,
  channel_name: string,
  author: { id, handle, display },
  text: string,
  ts: string,                 // ISO 8601 UTC
  permalink: string | null,
  meta: Record<string, unknown>,
}

All downstream classifiers and chains trust this shape. Adapters that can't fill a field return "" or null — never throw mid-map.

Verification#

bash# verify all adapters (auto-discovers snappy-*/adapter.ts)
npx tsx ~/.claude/skills/snappy-channel-contract/verify.ts

# verify one
npx tsx ~/.claude/skills/snappy-slack/adapter.ts

Every run appends certs to ~/.claude/logs/channel-certs.ndjson. Exit 0 = all green, exit 1 = at least one fail.

selfCheck() calls read(limit=3), checks shape, and tries identify() on the first event's author. It does not call post() (destructive).

How to use in a chain#

typescriptimport slack from "../snappy-slack/adapter.ts";
import gmail from "../snappy-email/adapter.ts";

const adapters = [slack, gmail /* ... */];
for (const a of adapters) {
  const events = await a.read();
  for (const e of events) {
    const intent = classify(e);
    if (intent === "bug-report") await bugChain(e, a);
  }
}

bugChain(e, a) calls a.post({ source: e.source, channel_id: e.channel_id, thread_id: e.thread_id, to_user: null }, { text, files }) — works identically on any channel.

How to add a new channel#

  1. Add the source literal to ChannelSource in types.ts.
  2. In the channel's skill directory, create adapter.ts exporting adapter: ChannelAdapter.
  3. Wrap existing api.ts functions (plus any sweep fetchers) to fill the Event shape.
  4. Run npx tsx <your-adapter>.ts — selfCheck must pass.
  5. Run the full verify — your adapter shows up automatically via discovery.

Adapter inventory (2026-04-13)#

source status notes
slack PASS wraps fetchSlackUnread + sendSlackMessage/replyInThread/getUserInfo
gmail PASS wraps fetchGmailUnread (work+personal) + createEmailDraft (draft-only by design)
linkedin PASS* wraps fetchLinkedInDMs + sendLinkedInDM from inbox-sweep. Browser auth expires periodically; read returns [] on expiry instead of throwing. identify is null until snappy-linkedin gains a profile-by-urn primitive.
skool PASS* wraps fetchSkoolNotifications + sendSkoolReply. Scraper occasionally hits 404 — returns [] gracefully.
telegram PASS native getRecentTelegramMessages + sendText. identify only returns bot self; per-user identify needs a future primitive.
whatsapp PASS reads from local webhook log, synthesizes thread_id from sender phone.
imessage FAIL SSH to Mac mini rejecting auth — pre-existing infra issue, not contract code. Adapter logic is correct.

*Auth-degraded but contract-compliant (graceful empty returns).

Rules#

  1. Never break the shape. If a channel can't fill a field, use "" or null, not undefined.
  2. read() must not throw. Degrade to [] on auth expiry, rate limit, network error. Errors go in the next selfCheck() via reproduced exception during sweep.
  3. post() must never auto-publish without intent. Gmail stays draft-only; other channels send directly (Robert's prior authorization for ephemeral comms).
  4. selfCheck() is read-only. Never call post() from it.
  5. No new channel skill without a contract-compliant adapter. The cert log is the gate.

<!-- SKILL-INDEX-START -->

[snappy-channel-contract Index]|root: ~/.claude/skills/snappy-channel-contract|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,SPEC.md}

<!-- SKILL-INDEX-END -->

Used by#

Nothing in the collection names this skill.

<!-- SNAPPY-CONTRACT-VERBS-START -->

Contract verbs#

Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.

Verb Contract arguments Effect First call
certs read npx tsx ~/.claude/skills/snappy-channel-contract/api.ts certs
list read npx tsx ~/.claude/skills/snappy-channel-contract/api.ts list
schema read npx tsx ~/.claude/skills/snappy-channel-contract/api.ts schema
validate read npx tsx ~/.claude/skills/snappy-channel-contract/api.ts validate

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

---
name: snappy-channel-contract
role: Uniform hands-and-eyes shape every channel skill implements
loaded-by: preload-skill-context hook
---

# snappy-channel-contract — The Hands-and-Eyes Layer

Every channel skill (snappy-slack, snappy-email, snappy-linkedin, snappy-whatsapp, snappy-imessage, snappy-telegram, snappy-skool) exports an `adapter.ts` conforming to this contract. Chains consume **only** the contract types — never the native api.ts shapes. That's what makes a chain channel-agnostic.

## The three verbs

```typescript
import type { ChannelAdapter, Event, PostTarget, PostContent } from "../snappy-channel-contract/types.ts";

const adapter: ChannelAdapter = {
  source: "slack",
  read(since?, limit?)    -> Promise<Event[]>
  post(target, content)   -> Promise<PostResult>
  identify(authorId)      -> Promise<Contact | null>
  selfCheck()             -> Promise<SelfCheckResult>
};
```

## Event shape (canonical)

```typescript
{
  source: "slack" | "gmail" | "linkedin" | ...,
  event_id: string,           // unique within source
  thread_id: string | null,   // null if channel has no threads
  channel_id: string,
  channel_name: string,
  author: { id, handle, display },
  text: string,
  ts: string,                 // ISO 8601 UTC
  permalink: string | null,
  meta: Record<string, unknown>,
}
```

All downstream classifiers and chains trust this shape. Adapters that can't fill a field return `""` or `null` — never throw mid-map.

## Verification

```bash
# verify all adapters (auto-discovers snappy-*/adapter.ts)
npx tsx ~/.claude/skills/snappy-channel-contract/verify.ts

# verify one
npx tsx ~/.claude/skills/snappy-slack/adapter.ts
```

Every run appends certs to `~/.claude/logs/channel-certs.ndjson`. Exit 0 = all green, exit 1 = at least one fail.

`selfCheck()` calls `read(limit=3)`, checks shape, and tries `identify()` on the first event's author. It does **not** call `post()` (destructive).

## How to use in a chain

```typescript
import slack from "../snappy-slack/adapter.ts";
import gmail from "../snappy-email/adapter.ts";

const adapters = [slack, gmail /* ... */];
for (const a of adapters) {
  const events = await a.read();
  for (const e of events) {
    const intent = classify(e);
    if (intent === "bug-report") await bugChain(e, a);
  }
}
```

`bugChain(e, a)` calls `a.post({ source: e.source, channel_id: e.channel_id, thread_id: e.thread_id, to_user: null }, { text, files })` — works identically on any channel.

## How to add a new channel

1. Add the source literal to `ChannelSource` in `types.ts`.
2. In the channel's skill directory, create `adapter.ts` exporting `adapter: ChannelAdapter`.
3. Wrap existing `api.ts` functions (plus any sweep fetchers) to fill the Event shape.
4. Run `npx tsx <your-adapter>.ts` — selfCheck must pass.
5. Run the full verify — your adapter shows up automatically via discovery.

## Adapter inventory (2026-04-13)

| source    | status | notes |
|-----------|--------|-------|
| slack     | PASS   | wraps `fetchSlackUnread` + `sendSlackMessage`/`replyInThread`/`getUserInfo` |
| gmail     | PASS   | wraps `fetchGmailUnread` (work+personal) + `createEmailDraft` (draft-only by design) |
| linkedin  | PASS*  | wraps `fetchLinkedInDMs` + `sendLinkedInDM` from inbox-sweep. Browser auth expires periodically; read returns [] on expiry instead of throwing. `identify` is null until snappy-linkedin gains a profile-by-urn primitive. |
| skool     | PASS*  | wraps `fetchSkoolNotifications` + `sendSkoolReply`. Scraper occasionally hits 404 — returns [] gracefully. |
| telegram  | PASS   | native `getRecentTelegramMessages` + `sendText`. `identify` only returns bot self; per-user identify needs a future primitive. |
| whatsapp  | PASS   | reads from local webhook log, synthesizes thread_id from sender phone. |
| imessage  | FAIL   | SSH to Mac mini rejecting auth — pre-existing infra issue, not contract code. Adapter logic is correct. |

*Auth-degraded but contract-compliant (graceful empty returns).

## Rules

1. **Never break the shape.** If a channel can't fill a field, use `""` or `null`, not `undefined`.
2. **`read()` must not throw.** Degrade to `[]` on auth expiry, rate limit, network error. Errors go in the next `selfCheck()` via reproduced exception during sweep.
3. **`post()` must never auto-publish without intent.** Gmail stays draft-only; other channels send directly (Robert's prior authorization for ephemeral comms).
4. **`selfCheck()` is read-only.** Never call `post()` from it.
5. **No new channel skill without a contract-compliant adapter.** The cert log is the gate.

<!-- SKILL-INDEX-START -->
[snappy-channel-contract Index]|root: ~/.claude/skills/snappy-channel-contract|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,SPEC.md}
<!-- SKILL-INDEX-END -->

## Used by

Nothing in the collection names this skill.

<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs

Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.

| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `certs` | — | `read` | `npx tsx ~/.claude/skills/snappy-channel-contract/api.ts certs` |
| `list` | — | `read` | `npx tsx ~/.claude/skills/snappy-channel-contract/api.ts list` |
| `schema` | — | `read` | `npx tsx ~/.claude/skills/snappy-channel-contract/api.ts schema` |
| `validate` | — | `read` | `npx tsx ~/.claude/skills/snappy-channel-contract/api.ts validate` |

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

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?