Channel Capture — Specification
Status: Draft for review Date: 2026-07-13 Scope: Email, WhatsApp, Telegram, Signal (position statement), PWA Share Target, Bluesky (AT Protocol), Mastodon / Fediverse (ActivityPub)
Tending should meet people where their relationships already live — email, messaging apps, and the open social web — and let them pull moments from those channels when they choose to. This spec defines a single shared ingestion architecture plus seven channel adapters.
1. Principles
- Deliberate capture, never sync. Tending never reads an inbox, a chat history, or a timeline. A moment only exists because a human forwarded, shared, mentioned, or messaged it to Tending. Consent is structural, not a checkbox. This is a stated product promise: "Tending never reads your inbox."
- Open protocols first. Where a channel has an open, federated protocol (AT Protocol, ActivityPub, SMTP), we implement it properly — real OAuth, real federation, no scraping, no unofficial client libraries.
- One pipeline, many doors. Every adapter normalises into the same
inbound shape and terminates in the existing
applyMomentSideEffectspipeline. Channel code is thin; intelligence (identity matching, AI understanding, thread synthesis) is shared. - Human in the loop. Machine-parsed moments land as suggested and are confirmed (or edited/discarded) by a person before they fully join the narrative. Confirmation should take one tap.
- Provenance is visible. Every ingested moment records which channel it came from and (where shareable) a link back to the source.
2. Shared architecture
2.1 The normalised inbound shape
Every adapter produces an InboundCapture and hands it to a single
ingestCapture() function in src/lib/ingest/:
interface InboundCapture {
channel: CaptureChannel; // "email" | "whatsapp" | "telegram" | "share" | "bluesky" | "mastodon"
organisationId: string; // resolved by the adapter from its binding
capturedByUserId: string | null; // the Tending user who caused the capture, if known
externalId: string; // channel-native unique id (Message-ID, wamid, at:// URI, status URI…)
externalUrl?: string; // canonical link back to the source, if public
text: string; // cleaned body text
attachments?: InboundAttachment[]; // images / audio to store or transcribe
participants: ChannelIdentity[]; // sender + other parties, as channel-native identifiers
occurredAt?: Date; // when the underlying interaction happened
raw?: Record<string, unknown>; // trimmed original payload, kept in aiExtraction for audit
}
interface ChannelIdentity {
kind: "email" | "phone" | "telegram" | "at_did" | "ap_actor";
value: string; // e.g. "[email protected]", "+447…", "did:plc:abc…", "https://mas.to/users/amina"
displayName?: string;
}
2.2 The pipeline
adapter (verify, parse, resolve org)
→ dedupe on (organisationId, channel, externalId)
→ checkMomentQuota(organisationId) // existing — billing applies to every door
→ matchConnectionsByIdentity(participants) // new deterministic matcher, §2.5
→ understandMoment(text, connections) // existing AI extraction for unresolved names/dates
→ transcribe audio attachments // existing transcription path
→ insert moment { source: channel, status: "suggested", aiExtraction }
→ applyMomentSideEffects(...) // existing: linking, network, qualities, thread, webhooks
→ channel confirmation (reply/DM/notification) // §2.6
2.3 Schema changes
Enum additions (src/lib/db/schema/enums.ts):
// momentSourceEnum gains:
"whatsapp" | "telegram" | "share" | "bluesky" | "mastodon"
// (email already exists)
export const momentStatusEnum = pgEnum("moment_status", [
"captured", // default; manually composed moments are captured immediately
"suggested", // machine-ingested, awaiting one-tap confirmation
"discarded", // rejected suggestion — kept briefly for undo, then purged by cron
]);
export const captureChannelEnum = pgEnum("capture_channel", [
"email", "whatsapp", "telegram", "share", "bluesky", "mastodon",
]);
moments gains status: momentStatusEnum (default captured) and
externalRef: text (nullable; <channel>:<externalId> for dedupe + linkback).
A partial unique index on (organisation_id, external_ref) WHERE external_ref
IS NOT NULL makes re-delivery idempotent.
New table capture_channels — per-org channel bindings, managed in
Settings → Channels:
capture_channels {
id uuid pk
organisationId uuid fk → organisations (cascade)
channel captureChannelEnum
// channel-specific binding, typed per channel:
// email: { token: "moss-fern-a8c2" } → [email protected]
// whatsapp: { } (binding is user-level, §4)
// telegram: { } (binding is user-level, §4)
// bluesky: { did, handle, pdsUrl, tokens… } org's authorised Bluesky account
// mastodon: { instanceUrl, accountId, tokens… } org's authorised Mastodon account
// ap_actor: { preferredUsername, keys… } org's native fediverse actor (§9.3)
config jsonb
active boolean default true
createdById uuid fk → users
createdAt / updatedAt timestamptz
}
Secrets inside config (OAuth tokens, signing keys) are encrypted at rest
with an app-level key (AES-GCM via ENCRYPTION_KEY env), never returned by
any API.
New table user_channel_links — binds a channel-native sender identity to
a Tending user, so an inbound WhatsApp/Telegram message knows who is
capturing and for which org:
user_channel_links {
id uuid pk
userId uuid fk → users (cascade)
organisationId uuid fk → organisations (cascade) // captures land in this org
kind text // "phone" | "telegram" | "at_did" | "ap_actor"
value text // E.164 phone, telegram user id, DID…
verifiedAt timestamptz
unique(kind, value) // one owner per identity
}
connections.contactDetails gains optional fields (JSONB — no
migration): bluesky?: string (handle or DID), fediverse?: string
(@user@instance), telegram?: string, whatsapp?: string (E.164). These
are the identity keys the matcher (§2.5) uses.
2.4 Routes and workers
src/app/api/ingest/email/route.ts POST — inbound-parse webhook (provider-signed)
src/app/api/ingest/whatsapp/route.ts GET+POST — Meta verification + inbound webhook
src/app/api/ingest/telegram/route.ts POST — bot webhook (secret-token header)
src/app/(app)/share/page.tsx GET/POST — PWA share target receiver
src/app/api/cron/social-mentions/route.ts POST — polls Bluesky + Mastodon notifications (§8, §9)
src/app/.well-known/webfinger/route.ts GET — fediverse actor discovery (§9.3)
src/app/api/ap/[org]/actor/route.ts GET — ActivityPub actor document
src/app/api/ap/[org]/inbox/route.ts POST — signed ActivityPub inbox
All ingest routes are unauthenticated in the session sense but authenticated
by channel-native means (provider signatures, webhook secrets, HTTP
Signatures). None of them accept an x-organisation-id header — the org is
always resolved from the verified binding, never from the caller.
Vercel constraint: everything is request/response or cron; no long-lived processes. This shapes the Bluesky design (§8): we poll notifications on a cron rather than consuming the firehose/Jetstream, which needs a persistent worker we don't have. ActivityPub, by contrast, is push-based HTTP and fits serverless perfectly.
2.5 Identity matching
New matchConnectionsByIdentity(organisationId, participants) in
src/lib/ingest/identity.ts:
- Exact match on
contactDetailsfields (email, phone, bluesky, fediverse, telegram) — deterministic, auto-links. - Unmatched participants fall through to
understandMoment's existing name-based suggestion (usingdisplayName), surfaced as suggestions in the review step, alongside its existing "new connection" proposals. - Confirming a suggested link writes the identity back to the connection's
contactDetails, so matching gets deterministic over time.
The capturing user is never linked as a participant connection.
2.6 Review & confirmation
- Ingested moments are
status: "suggested". They appear in a Review queue (badge in nav, similar surface to observations) showing parsed content, matched/suggested connections, and source link. Actions: Confirm (→captured), Edit then confirm, Discard. - Side effects that are hard to unwind (network strengthening, quality
inference, thread synthesis,
moment.createdwebhook) run on confirmation, not on ingest.applyMomentSideEffectsgains adeferredflag or is simply invoked at confirm time. - Where the channel supports it, the adapter replies with a short confirmation: email auto-reply, WhatsApp/Telegram bot reply, Bluesky/ Mastodon reply or DM — "Captured ✓ — linked to Amina K. Review: ". Replies are best-effort and never block ingestion.
- An org-level setting
autoConfirmTrustedChannels(default off) lets mature orgs skip review for deterministic captures (all participants matched, no new connections proposed).
2.7 Security baseline (all adapters)
- Verify the channel's native signature/secret before any parsing:
provider signature (email),
X-Hub-Signature-256HMAC (WhatsApp),X-Telegram-Bot-Api-Secret-Token(Telegram), HTTP Signatures (ActivityPub), OAuth/DPoP (Bluesky). - Treat all inbound content as untrusted: length-cap text (10k chars, same as API), strip HTML to text, sanitise, never render raw HTML.
- Attachments: allowlist MIME types (images + audio), size caps, store via the existing attachments path.
- Rate-limit per binding (e.g. 60 captures/hour/org) ahead of quota checks.
- Dedupe idempotently (unique
externalRefindex) — every provider redelivers. - Log ingest failures to a lightweight
ingest_eventstable (or reuse webhook-delivery-style status) for debuggability; never 500 back to providers for content errors (ack 200 and record), only for transient faults (so they retry).
3. Email
Model: forward-to-capture and BCC-to-capture.
- Each org gets a capture address shown in Settings → Channels:
"{token}@in.tending.network"wheretokenis a readable random slug (e.g.moss-fern-a8c2), stored incapture_channels.config, rotatable (rotation deactivates the old address). - Provider: any inbound-parse service (Resend/Postmark/SendGrid — pick
whichever already sends Tending's outbound mail). MX for
in.tending.networkpoints at the provider; provider POSTs parsed MIME to/api/ingest/emailwith a signature we verify. - Parsing: prefer
text/plain; fall back to HTML→text. Strip quoted history and signatures (talon-style heuristics; the AI layer tolerates leftovers).externalId= RFC 5322Message-ID.occurredAt=Dateheader. - Participants: From/To/Cc minus the capture address and the forwarder's
own address. On a forwarded email, the original correspondents typically
live in the forwarded headers inside the body — extraction of
From:-lines in the body is attempted, andunderstandMomentcovers the rest. capturedByUserId: matched by the SMTPFromagainst org members' account emails. Unknown senders to a valid capture address are recorded but held in review with a warning (the address is a capability token; treat mail from non-members as untrusted).- Attachments: images kept as moment attachments; audio attachments transcribed.
- Confirmation: auto-reply from
[email protected]with the capture summary and review link. Loop protection: never auto-reply to auto-replies (Auto-Submitted,Precedence: bulk), never reply more than once per Message-ID.
Habit to teach: BCC the capture address when emailing a contact — capture at the moment of correspondence with zero extra steps.
4. WhatsApp
Model: users forward messages or send voice notes to Tending's WhatsApp number. One platform-wide number (WhatsApp Business Cloud API), sender identified by phone.
- Prereqs: Meta Business verification, WhatsApp Business Platform app, one
phone number, webhook subscription to
messages. This has lead time — start verification early; Telegram (§5) ships first as the same-shaped cheap proof. - Binding: user verifies their mobile number in Settings → Channels (we send
a code via the WhatsApp number; replying binds it). Creates a
user_channel_linksrow{kind: "phone", value: E.164, organisationId}. Users in multiple orgs pick a default; sending the org's name as the first line of a message overrides per-capture. - Webhook (
/api/ingest/whatsapp): verifyX-Hub-Signature-256HMAC with the app secret; handle Meta's GET challenge.externalId=wamid. - Inbound handling:
- Text → capture text.
- Voice note → download media (short-lived Meta media URL) → existing transcription path → capture transcript (audio kept as attachment). This is the flagship flow for community organisations.
- Forwarded messages: WhatsApp does not expose the original sender —
participants come purely from
understandMomenton the content. Set expectations in the UI copy. - Images with captions → attachment + caption text.
- Unknown sender (no
user_channel_linksmatch): reply once with a short "link your number in Tending Settings" message; do not store content. - Confirmation: bot replies with capture summary + review link.
- 24-hour customer-service window: replies to an inbound message are session messages (free-form, no template needed) — our confirmation always falls inside it.
5. Telegram
Model: identical shape to WhatsApp, near-zero platform friction. Ships first among messengers to prove the messaging adapter pattern.
- One bot (
@TendingCaptureBot) via BotFather. Webhook mode:setWebhook(url, secret_token); verifyX-Telegram-Bot-Api-Secret-Tokenon every POST to/api/ingest/telegram. - Binding: Settings → Channels shows a deep link
https://t.me/TendingCaptureBot?start={one-time-token}. The/startpayload bindstelegram user id → user + orginuser_channel_links. No phone number needed. - Inbound: text, voice (OGG/Opus → transcription path), photos with
captions.
externalId="{chat_id}:{message_id}". - Forwarded messages preserve provenance:
forward_origincarries the original sender's name (and id, visibility permitting) — feed it intoparticipantsas adisplayName, giving better matching than WhatsApp. - Bot replies with capture summary + review link. Unknown senders get the bind prompt once.
6. Signal — position statement
We do not build a direct Signal integration. Signal has no official public bot/API for this use case; the unofficial routes (signal-cli, libsignal registration of a number) are against Signal's terms, fragile, and at odds with "open protocols first". Building on them would be a maintenance and trust liability.
Signal is served by the PWA share target (§7): on mobile, Share → Tending from any Signal message puts the text straight into quick capture. This is the honest answer for every closed messenger, and it needs zero Signal-specific code. Revisit only if Signal ships an official API.
7. PWA Share Target
Model: Tending appears in the native share sheet on Android (and progressively elsewhere). One mechanism covers Instagram, LinkedIn, X, Signal, iMessage-adjacent apps — every channel we will never get an API for.
public/manifest.webmanifestgains:
{
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title", "text": "text", "url": "url",
"files": [{ "name": "media", "accept": ["image/*", "audio/*"] }]
}
}
}
/share(authenticated app page): receives the POST, pre-fills the existing quick-capture composer with shared text/URL/images, runsunderstandMomentas quick capture already does. Because a human is present and reviewing in-composer, these moments are created directly ascapturedwithsource: "share"— no review queue.- Shared URLs: fetch OpenGraph title/description server-side (timeout-capped,
SSRF-guarded: https only, public DNS, no redirects to private ranges) to
enrich the content; keep the URL as
externalUrl. - iOS Safari doesn't support
share_target; iOS users are covered by the channels above. (A future minimal iOS share extension is out of scope.) - Prereq: installable PWA (manifest + icons + service worker shell). Keep the service worker minimal (no offline sync in this phase).
8. Bluesky — AT Protocol, done properly
Model: each org connects its own Bluesky account via atproto OAuth (no app passwords). Two capture flows: paste/share a post URL, and opt-in mention/DM watching.
8.1 Connection (OAuth)
- Tending is a confidential atproto OAuth client: publish a client
metadata document at
https://tending.network/oauth/bsky-client-metadata.json(client_id is that URL), implement PAR (pushed authorization requests), DPoP proof-of-possession on every token use, and token refresh. Scopes:atproto transition:generic(andtransition:chat.bskyonly if DM capture is enabled). - Flow: Settings → Channels → "Connect Bluesky" → user enters their handle →
resolve handle → DID (
com.atproto.identity.resolveHandle+ DID doc) → discover the PDS and its authorization server → PAR → user authorises on their PDS → callback stores{did, handle, pdsUrl, tokens}encrypted incapture_channels.config. - All subsequent calls go to the account's own PDS (federation-correct —
works for accounts on any PDS, not just bsky.social), with the public
AppView (
https://public.api.bsky.app) used for unauthenticated reads of public content.
8.2 Capture flows
- Share/paste a post (works with the PWA share target and in-app): given
a
https://bsky.app/profile/{handle}/post/{rkey}URL orat://URI, resolve to the canonicalat://did/app.bsky.feed.post/rkey, fetch viaapp.bsky.feed.getPostThread(public AppView; no auth needed for public posts), capture post text + author.externalId= at:// URI,externalUrl= bsky.app URL. Participants: author's DID + handle. - Mentions & replies (opt-in per org): the
/api/cron/social-mentionscron (every 10 min, matching existing cron cadence) callsapp.bsky.notification.listNotificationswith the stored cursor (seenAt), filters tomentionandreply, and ingests each as a suggested moment. This lets fieldworkers capture by simply mentioning the org's account in a post — "great session with @northside.food.bank today" becomes a moment on both sides' consent (public post, explicit mention). - DMs (opt-in, later): poll
chat.bsky.convo.*(service-proxied) for new messages to the org account; same pipeline. Requires the chat scope; ship after 1–2 prove out.
Why polling, not the firehose: Jetstream/firehose consumption needs a
persistent connection, which the Vercel runtime can't hold. Notification
polling with a cursor is exactly-once, cheap, serverless-shaped, and
sufficient at 10-minute freshness. If real-time ever matters, a small
external worker (Fly/Railway) can consume Jetstream filtered to
app.bsky.feed.post mentioning bound DIDs and POST into /api/v1/moments —
the architecture doesn't change.
- Identity: store DIDs (stable), display handles (mutable) in
contactDetails.bluesky; match on either, canonicalise to DID. - Confirmation for mention-captures: optional reply from the org account (off by default — replying publicly is a visible act; per-org setting).
9. Mastodon / Fediverse — ActivityPub, done properly
Two tiers, shipped in order. Tier 1 is a standard Mastodon-API integration; Tier 2 makes Tending itself a first-class fediverse citizen.
9.1 Tier 1 — Mastodon client API (per-instance OAuth)
- Settings → Channels → "Connect Mastodon" → user enters their instance
domain → dynamic app registration (
POST /api/v1/apps— apps are per-instance; cache client credentials per instance in a smallmastodon_instancestable) → standard OAuth 2 authorization-code flow → store account tokens encrypted incapture_channels.config. Scopes:read:notifications read:statuses(+write:statusesonly if reply confirmations enabled). - Capture flows (mirror Bluesky's):
- Share/paste a status URL → resolve via
GET /api/v2/search?q={url}&resolve=trueon the org's home instance → capture status text (HTML→text) + author.externalId= status URI (the canonical ActivityPub id),externalUrl= status URL. - Mentions (opt-in): same
/api/cron/social-mentionscron pollsGET /api/v1/notifications?types[]=mention&since_id={cursor}. Mentioning@[email protected]in a toot captures a moment. - DMs: Mastodon DMs are just
visibility: directstatuses — they arrive through the same notifications poll. Capture them as suggested moments flagged private. - Works with Mastodon-compatible APIs beyond mastodon.social (Hometown, GoToSocial largely compatible); degrade gracefully on missing endpoints.
- Identity:
contactDetails.fediversestores@user@instance; canonical key is the actor URI.
9.2 Rate limits & etiquette
Respect per-instance rate limits (300 req/5 min default, headers-driven
backoff); instances are someone's community infrastructure. Set a proper
User-Agent (Tending/1.x (+https://tending.network)).
9.3 Tier 2 — native ActivityPub actor (full federation)
Each org can enable a first-class fediverse actor,
@{org-slug}@tending.network, followable and mentionable from any
ActivityPub server — no Mastodon account required by the org at all.
- WebFinger:
/.well-known/webfinger?resource=acct:{slug}@tending.network→ actor URL. - Actor document:
GET /api/ap/{org}/actor→ JSON-LD (application/ activity+json)Service/Groupactor with inbox, outbox, publicKey (RSA keypair per actor, generated on enable, private key encrypted incapture_channels.config). - Inbox:
POST /api/ap/{org}/inbox— verify HTTP Signatures (draft-cavage as deployed by Mastodon; fetch and cache the remote actor's public key), then handle: Create(Note)mentioning the actor, and direct Notes → ingest as a suggested moment (this is the capture path: anyone in the fediverse can send a moment to the org by mentioning or DMing its Tending actor).Follow→ auto-Accept(signed POST back to the follower's inbox).Delete/Undo→ honour: if a fediverse Note that became a moment is deleted upstream, flag the moment in review (don't silently delete org data; show provenance-revoked state).- Outbox/delivery: minimal —
Acceptactivities and (optional) confirmation replies, delivered as signed POSTs through the existing webhook-delivery-style retry queue (reuse thewebhookDeliveriespattern with a new delivery kind rather than a parallel mechanism). - ActivityPub is push-based HTTP — a perfect fit for serverless, unlike firehose consumption. Inbox POSTs arrive like any webhook.
- Non-goals for Tier 2 (explicitly out of scope): publishing org content to the fediverse, follower timelines, boosts/likes, Mastodon-compatible client API on tending.network. The actor exists to receive, accept follows, and reply confirmations — a capture endpoint with a face, not a social platform.
Tier 2 risk note: HTTP Signature interop across fediverse software is the
known hard part (key formats, (created) vs date, RFC 9421 migration).
Budget test time against Mastodon, GoToSocial, and Pleroma; keep Tier 1 as
the fallback path for orgs where interop bites.
10. Privacy & data protection
- Lawful basis / roles: the org is data controller for captured content; Tending is processor. Channel capture is off by default and enabled per-channel by an org admin (Settings → Channels).
- Third-party data: forwarded emails and messages contain other people's
personal data. Mitigations: deliberate-capture-only (a human chose this
content), review queue before it joins the narrative, visible provenance,
and the existing export/delete paths (
/api/v1/export, cascade deletes) covering ingested moments identically to manual ones. - Public-post nuance: a public toot/skeet is still personal data.
Capture only on explicit share or explicit mention of the org's account —
never keyword search or timeline monitoring. Honour upstream
Deleteactivities (§9.3). - Storage minimisation:
rawpayloads trimmed to what the extraction needs; provider media URLs (WhatsApp) fetched once, stored in our storage, provider copy expires. Discarded suggestions purge content after 14 days (cron). - Secrets: all channel tokens/keys encrypted at rest; never in logs; webhook payload logging redacts message bodies.
- Update the privacy policy and org-facing copy per channel before each channel GA.
11. Build phases
| Phase | Ships | Depends on |
|---|---|---|
| 1 | Shared pipeline (src/lib/ingest/), schema (enums, moments.status/externalRef, capture_channels, user_channel_links), review queue UI, Email adapter |
inbound email provider config, in.tending.network MX |
| 2 | PWA share target (manifest, /share page, installability) |
none |
| 3 | Telegram bot (binding flow, webhook, voice transcription) | BotFather setup |
| 4 | Bluesky: atproto OAuth client, paste/share capture, mentions cron | OAuth client metadata hosting |
| 5 | Mastodon Tier 1: per-instance OAuth, paste/share capture, mentions in shared cron | none |
| 6 | WhatsApp Cloud API adapter (phone binding, voice notes) | Meta business verification (start during Phase 1 — longest lead time) |
| 7 | ActivityPub Tier 2: WebFinger, org actors, signed inbox, Accept/Delete handling | Phases 1 & 5 learnings |
Each phase lands behind an org-level channel toggle; nothing is on by default. Signal ships as documentation ("use Share → Tending"), not code.
12. Open questions
- Inbound email provider: consolidate on the current outbound provider, or pick Postmark for its superior inbound parsing?
- Review queue placement: its own nav item, or folded into the observations surface as a "suggested moments" section?
- Should mention-capture (Bluesky/Mastodon) be per-space routable — e.g. hashtags in the post mapping to spaces?
- Multi-org users on messaging channels: is "first line = org name" override enough, or do we need a bot-side org picker?
- Tier 2 actor type:
Service(bot-flagged in Mastodon UI, honest) vsPerson/Group(better visibility in some clients)? - Does channel capture count against the same moment quota 1:1, or do suggested-but-discarded moments not count?