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/apiand theapp.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.postrecord created viacom.atproto.repo.createRecord. - XRPC — all calls are XRPC over HTTPS to the user's PDS / the AppView.
@atproto/api'sAtpAgentwraps them (agent.getTimeline(),agent.post(), …). - Auth — AT 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-time — Jetstream (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 PAT —
chat+events.subscribe, plusrepresent(+ optionalexpose:*/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 represent ⇒ 403.
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/parentrefs) 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)
- 代聊 DMs (§3) — highest value, reuses the Matrix bridge shape 1:1.
- 代读 triage (§4) — owner-facing, no new privacy surface.
- 代发 (§5) — add the draft→approve→post UX (the careful one).
- 配对 (§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.