snappy-channel-contract skill
certsreadlistreadschemareadvalidateread$ npx snappy-skills install snappy-channel-contract
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.
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>
};
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.
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).
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.
ChannelSource in types.ts.adapter.ts exporting adapter: ChannelAdapter.api.ts functions (plus any sweep fetchers) to fill the Event shape.npx tsx <your-adapter>.ts — selfCheck must pass.| source | status | notes |
|---|---|---|
| slack | PASS | wraps fetchSlackUnread + sendSlackMessage/replyInThread/getUserInfo |
| gmail | PASS | wraps fetchGmailUnread (work+personal) + createEmailDraft (draft-only by design) |
| 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. |
| 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).
"" or null, not undefined.read() must not throw. Degrade to [] on auth expiry, rate limit, network error. Errors go in the next selfCheck() via reproduced exception during sweep.post() must never auto-publish without intent. Gmail stays draft-only; other channels send directly (Robert's prior authorization for ephemeral comms).selfCheck() is read-only. Never call post() from it.<!-- 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 -->
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
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 |
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 -->