Pouchy Companion — Bluesky / AT Protocol bridge (spec)

Companion plane: REST+SSE; the JS SDK (@pouchy_ai/companion-sdk) wraps it.

How a user's Pouchy companion plugs into Bluesky / the AT Protocol — three capabilities, each mapping onto an existing companion mode:

Capability Companion mode Who it serves
代聊 — answer the user's Bluesky DMs on their behalf representative (visitor = sender) people messaging the user
代读 — triage the user's timeline / mentions / notifications owner-facing (feed as context) the user
代发 — draft & post replies/posts on the user's behalf owner-facing (companion drafts; bridge posts) the user
社交图谱 → 配对 — a Bluesky connection who's also a Pouchy user becomes a friend pairVisitor (visitor = their DID) both users' companions

The companion never speaks AT Protocol itself — a bridge service does, mapping Bluesky ⇄ the companion API. This is the same shape as the Matrix bridge (companion-native-integration.md §4); only the protocol adapter differs.

Status: spec + reference. This environment has no AT Protocol runtime, so the code below is reference (not compiled/tested). Names follow @atproto/api and the app.bsky.* / chat.bsky.* lexicons — verify against current atproto docs before shipping (the chat lexicons in particular evolve).


1. AT Protocol primer (the parts that matter here)

  • Identity — every account is a DID (did:plc:…), with a human handle (alice.bsky.social). The DID is the stable id; use it as the pairing/visitor key.
  • Repo + records — each user's data lives in their PDS as typed records (lexicons). A post is an app.bsky.feed.post record created via com.atproto.repo.createRecord.
  • XRPC — all calls are XRPC over HTTPS to the user's PDS / the AppView. @atproto/api's AtpAgent wraps them (agent.getTimeline(), agent.post(), …).
  • AuthAT Protocol OAuth is the modern way for a service to act on a user's behalf (scoped, revocable); legacy app passwords also work. The bridge holds, per opted-in user, an AT session and their Pouchy PAT.
  • DMs — Bluesky chat is the chat.bsky.convo.* lexicons, reached via the chat proxy header (atproto-proxy: did:web:api.bsky.chat#bsky_chat).
  • Real-timeJetstream (filtered JSON firehose) or polling (chat.bsky.convo.getLog, app.bsky.notification.listNotifications) to learn of new DMs / mentions.

2. Architecture

                    ┌─────────────────────── bridge service (Node) ───────────────────────┐
 Bluesky (PDS /     │  per user A:  AT session (OAuth)  +  Pouchy PAT                       │
 AppView / chat) ◀──┤                                                                       │
   DMs / feed /     │  代聊:  DM from B ─▶ representative session(A's PAT, visitor=B) ─▶ reply ─▶ chat.bsky.convo.sendMessage
   notifications    │  代读:  timeline/notifs ─▶ owner-facing turn (feed as context) ─▶ digest/suggestions to A
                    │  代发:  companion draft ─▶ SHOW A ─▶ A approves ─▶ agent.post()        │
                    │  配对:  B is a Pouchy user ─▶ c.pairVisitor(B's PAT)                   │
                    └───────────────────────────────────────────────────────────────────────┘

Two credentials per user, both held by the bridge:

  • Pouchy PATchat + events.subscribe, plus represent (+ optional expose:* / represent:remember / represent:pair) for 代聊/配对.
  • AT session — OAuth (or app password) to read the feed and post/DM as the user.

visitor.id from a DID must match ^[A-Za-z0-9_-]{8,64}$, and a DID has colons — encode it stably (same as the Matrix bridge):

import { createHash } from 'node:crypto';
const encVisitor = (did: string) => createHash('sha256').update(did).digest('hex').slice(0, 48);

3. 代聊 — answer Bluesky DMs (representative mode)

Direct analog of the Matrix bridge. New inbound DM → representative session keyed by the sender's DID → screened reply → send back via the chat lexicon.

// npm i @pouchy_ai/companion-sdk @atproto/api
import { createCompanion } from '@pouchy_ai/companion-sdk';
import { AtpAgent } from '@atproto/api';

const BASE = 'https://pouchy.ai';

// `agent` is A's authenticated AT session; `convo` a chat.bsky.convo with a new
// message from `senderDid`. Your app maps A ↔ A's Pouchy PAT.
async function handleDM(agent: AtpAgent, ownerDid: string, ownerPat: string,
                        convoId: string, senderDid: string, senderHandle: string, text: string) {
  if (senderDid === ownerDid) return;                  // only field messages TO the owner

  const c = createCompanion({
    baseUrl: BASE,
    token: ownerPat,                                    // holds `represent`
    surface: `bsky:${convoId}`,                         // one resumable session per (A, convo)
    appContext: { name: 'Bluesky DMs', description: 'AT Protocol direct message' },
    visitor: { id: encVisitor(senderDid), displayName: senderHandle }
  });
  await c.connect();                                    // → { representative: true, … }

  const reply = await new Promise<string>((resolve) => {
    const off = c.onMessage((t) => { off(); resolve(t); });
    c.start();
    c.sendText(text).catch(() => resolve(''));
  });
  c.stop();

  if (reply) {
    // Send via the chat proxy (chat.bsky.convo.sendMessage).
    const chat = agent.withProxy('bsky_chat', 'did:web:api.bsky.chat');
    await chat.chat.bsky.convo.sendMessage({ convoId, message: { text: reply } });
  }
}

Discover new DMs by polling chat.bsky.convo.listConvos / getLog, or via Jetstream. Privacy is inherited exactly as in the Matrix bridge: screened context only, visitor input quarantined, represent:remember gives durable per-visitor notes, no represent403.


4. 代读 — triage the timeline / mentions (owner-facing)

Here the companion works for the user on their own feed, so it's an ordinary owner-facing session (it may use the user's real memory — this is their assistant, not a stranger-facing rep). Feed items go in as world-state (compact, gated) and/or as the turn text; the companion summarises, flags what needs a reply, drafts.

const c = createCompanion({ baseUrl: BASE, token: ownerPat, surface: 'bsky:triage' });
await c.connect(); c.start();

// Pull what's new and hand it to the companion as context.
const notifs = await agent.app.bsky.notification.listNotifications({ limit: 30 });
const items = notifs.data.notifications
  .filter((n) => ['mention', 'reply', 'quote'].includes(n.reason))
  .map((n) => `@${n.author.handle} (${n.reason}): ${(n.record as any)?.text ?? ''}`);

c.sendWorldState({ type: 'bsky.notifications', data: items, retained: true });
const summary = await new Promise<string>((r) => { const off = c.onMessage((t)=>{off();r(t);}); 
  c.sendText('Triage my Bluesky mentions — what needs a reply, and draft each?'); });
// → show `summary` to the user in-app; each draft becomes a 代发 candidate (§5).

Because it's owner-facing, anything durable the companion learns here lands in the user's normal memory (consistent with how Pouchy works inside the first-party app).


5. 代发 — post / reply on the user's behalf (draft → approve → post)

The companion drafts; the bridge posts. Posting publicly as the user is high-trust, so it is never auto-sent — the bridge shows the draft to the user and only calls agent.post() after explicit approval. (Pouchy's own confirm boundary gates Pouchy actions like wallet/skills; Bluesky posting happens on the AT side with the user's token, so the approval UX is the bridge's responsibility.)

// 1. Companion produces a draft (owner-facing turn, e.g. from §4).
const draft = await draftReplyWithCompanion(c, mention);

// 2. Bridge shows `draft` to the user in-app and waits for an explicit tap.
if (await userApproved(draft)) {
  // 3. Post it as the user (app.bsky.feed.post via createRecord).
  await agent.post({
    text: draft,
    reply: mention ? { root: mention.root, parent: mention.parent } : undefined
  });
}

Guidance to bake into the bridge:

  • Always human-approve posts/replies; never a fully-autonomous poster.
  • Keep within Bluesky's grapheme limit (~300) — have the companion target it.
  • Preserve reply threading (root/parent refs) when replying.
  • Rate-limit + log; treat it like any "act as the user publicly" capability.

6. 社交图谱 → 访客配对

A Bluesky connection (mutual / follower) who is also a Pouchy user can be paired so the two companions become friends and the A2A plane lights up — same as the Matrix path, keyed by DID.

// Your app knows which Bluesky DIDs are Pouchy users (it onboarded them) and can
// obtain that user's own Pouchy PAT (holding `social.message`) with their consent.
const c = createCompanion({ baseUrl: BASE, token: ownerPat,         // holds `represent:pair`
  surface: `bsky:${convoId}`, visitor: { id: encVisitor(peerDid), displayName: peerHandle } });
await c.connect();
await c.pairVisitor(peerPouchyPat);     // → writes the canonical pair; both apps see the friend

Use it to turn a "we follow each other on Bluesky" signal into a real agent-to-agent friendship (messaging, gifts, visiting) inside Pouchy.


7. Consent & privacy summary

Path Mode Memory Consent
代聊 (DMs) representative visitor-quarantined; per-visitor notes only w/ represent:remember owner granted represent; DM sender is talking to the rep
代读 (triage) owner-facing normal owner memory (it's the user's own assistant) the user ran it
代发 (post) owner-facing draft explicit per-post user approval (bridge UX)
配对 pairVisitor two-token: owner represent:pair + peer's PAT (social.message)

Hard lines the companion enforces server-side regardless of the bridge: a representative session never exposes the owner's system prompt, private memory, intimate facts, or PII; a visitor without represent is rejected; first-party Pouchy tools (wallet/skills/social) don't run in representative mode.


8. Build order (suggested)

  1. 代聊 DMs (§3) — highest value, reuses the Matrix bridge shape 1:1.
  2. 代读 triage (§4) — owner-facing, no new privacy surface.
  3. 代发 (§5) — add the draft→approve→post UX (the careful one).
  4. 配对 (§6) — once you have the Bluesky-DID ↔ Pouchy-uid mapping.

The companion API is identical across all four — the only Bluesky-specific work is the AT Protocol adapter (auth, read feed/DMs, post, send DM). The JS SDK (@pouchy_ai/companion-sdk) runs server-side in the bridge; read src/lib/companion-sdk/client.ts and companion-api-reference.md when a companion-side detail is ambiguous.