Pouchy Companion — Native (Android / iOS) + Matrix bridge integration
For a native app (Kotlin / Swift). The @pouchy_ai/companion-sdk npm package is
JavaScript (browser + Node) and cannot be imported into native code — but it's
only a thin convenience wrapper over a plain HTTPS REST + Server-Sent Events
contract, which a Kotlin or Swift client implements directly. This guide is that
contract, with reference clients you can drop in, plus a Matrix 代聊 bridge for
letting a user's companion field their Matrix DMs on their behalf.
The wrapper SDK's behaviour is the spec: see
src/lib/companion-sdk/client.ts. The full HTTP reference iscompanion-api-reference.md.
0. The shape of it
Two integration points, deliberately separate:
| Where | What runs | Talks to |
|---|---|---|
| In the app (Kotlin/Swift) | the user's OWN companion — chat, voice, the avatar | the REST+SSE plane below |
| A small bridge service (server-side) | the 代聊 path: the companion answers the user's Matrix DMs from other people, on their behalf | the same plane, in representative mode — and Matrix |
The bridge is best server-side (always-on, listens to Matrix). It can reuse the JS SDK directly (Node), so §4 is JS; §2–§3 are the native clients for the app itself.
1. Contract basics
- Base URL:
https://pouchy.ai - Auth: every call carries
Authorization: Bearer pchy_…(a Personal Access Token the user mints viaPOST /api/companion/keyswhile signed in — no in-app Wallet panel for this yet — or an OAuth token). The SSE stream also accepts the token as a?access_token=query param (for SSE clients that can't set headers). - Scopes the token needs for this integration:
chat,events.subscribe(receive replies),voice/call(optional, voice), and — for 代聊 —represent(+ optionalexpose:knowledge,expose:facts,represent:remember,represent:pair). Scopes are chosen at token creation; everything is gated server-side. - Transport: REST for everything you send; one SSE stream for everything the
companion sends back (its reply lands as a
companion.messageevent, not in the POST response).
The core loop
POST /api/companion/session → { session, resumeCursor, … }
GET /api/companion/session/{session}/stream?cursor=0 (open SSE, keep it open)
POST /api/companion/session/{session}/input {text} (the reply arrives on the stream)
← event: companion data: {"type":"companion.message","payload":{"text":"…"}}
Endpoints used here
| Method | Path | Body → Response |
|---|---|---|
POST |
/api/companion/session |
{ surface?, modalities?, appContext?, visitor? } → { ok, session, grantedScopes, modalities, resumeCursor, representative, visitorPaired } |
GET |
/api/companion/session/{session}/stream?cursor=N |
SSE — see below. Needs events.subscribe. |
POST |
/api/companion/session/{session}/input |
{ text, images? } → { ok, kind:"message", seq }. Needs chat. |
POST |
/api/companion/session/{session}/end |
{ transcript? } → { ok } (consolidate on close) |
POST |
/api/companion/pair |
{ visitorToken, visitorId } → { ok, pairId }. Needs represent:pair. |
SSE wire format (exact)
The stream sends standard SSE frames. Each frame has an event: name and a JSON
data: line. It runs ~45s then closes cleanly with a reconnect frame carrying
the next cursor — reconnect with ?cursor=<that> (this is normal, not an error).
event: open
data: {"type":"hello.ack","payload":{"resumeCursor":12},"session":"sess_…"}
event: companion
data: {"v":1,"id":"evt_…","ts":1718900000,"type":"companion.message","payload":{"text":"Hi!"}}
: ping ← heartbeat comment, ignore
event: reconnect
data: {"cursor":13} ← reopen the stream with ?cursor=13
event: open— first frame;payload.resumeCursoris where you are.event: companion— a companion event. Switch ondata.type:companion.message(payload.text— the reply),companion.tool_call,companion.confirm_request,control.error(payload.code/message), …event: reconnect— bounded-lifetime close; immediately reopen with?cursor=data.cursor. Track the highest cursor you've seen and pass it on reconnect so you never miss or duplicate an event.
2. Android — Kotlin reference client
Uses OkHttp for REST and okhttp-sse for the
stream.
// build.gradle: implementation("com.squareup.okhttp3:okhttp:4.12.0")
// implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
import okhttp3.*
import okhttp3.sse.*
import org.json.JSONObject
class PouchyCompanion(
private val baseUrl: String,
private val token: String,
private val surface: String = "android",
/** Pass for 代聊 / representative mode; null for the user's own companion. */
private val visitor: Pair<String, String?>? = null // (id, displayName?)
) {
private val http = OkHttpClient.Builder()
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // SSE: no read timeout
.build()
private val json = "application/json".toMediaType()
private var session: String? = null
private var cursor: Int = 0
private var es: EventSource? = null
private fun req(path: String) = Request.Builder()
.url(baseUrl.trimEnd('/') + path)
.header("Authorization", "Bearer $token")
/** Handshake. Returns the session id; populates representative/visitorPaired. */
fun connect(): JSONObject {
val body = JSONObject().put("surface", surface)
visitor?.let { (id, name) ->
body.put("visitor", JSONObject().put("id", id).apply { name?.let { put("displayName", it) } })
}
http.newCall(req("/api/companion/session").post(body.toString().toRequestBody(json)).build())
.execute().use { res ->
val ack = JSONObject(res.body!!.string())
check(res.isSuccessful && ack.optBoolean("ok")) { "connect failed: ${ack.optString("error")}" }
session = ack.getString("session")
cursor = ack.optInt("resumeCursor", 0)
return ack // { session, representative, visitorPaired, grantedScopes, … }
}
}
/** Open the auto-reconnecting event stream. onMessage receives reply text. */
fun start(onMessage: (String) -> Unit, onError: (String) -> Unit = {}) {
val s = session ?: error("call connect() first")
// Header-based auth works for OkHttp SSE (unlike browser EventSource).
val request = req("/api/companion/session/$s/stream?cursor=$cursor").build()
es = EventSources.createFactory(http).newEventSource(request, object : EventSourceListener() {
override fun onEvent(es: EventSource, id: String?, type: String?, data: String) {
when (type) {
"reconnect" -> { // bounded close → reopen
cursor = JSONObject(data).optInt("cursor", cursor)
start(onMessage, onError)
}
"open" -> cursor = JSONObject(data).optJSONObject("payload")?.optInt("resumeCursor", cursor) ?: cursor
"companion" -> {
val env = JSONObject(data)
if (env.optString("type") == "companion.message")
onMessage(env.getJSONObject("payload").optString("text"))
if (env.optString("type") == "control.error")
onError(env.getJSONObject("payload").optString("message"))
}
}
}
override fun onFailure(es: EventSource, t: Throwable?, res: Response?) {
// 401/403 is permanent (missing events.subscribe / revoked) — surface it.
if (res?.code == 401 || res?.code == 403) onError("stream unauthorized (${res.code})")
else { Thread.sleep(1500); start(onMessage, onError) } // transient → backoff + retry
}
})
}
fun stop() { es?.cancel(); es = null }
/** Send a user turn. The reply arrives via the onMessage you passed to start(). */
fun sendText(text: String) {
val s = session ?: error("call connect() first")
val body = JSONObject().put("text", text).toString().toRequestBody(json)
http.newCall(req("/api/companion/session/$s/input").post(body).build()).execute().close()
}
}
Usage (the user's own companion):
val c = PouchyCompanion(baseUrl = "https://pouchy.ai", token = userPat)
c.connect()
c.start(onMessage = { reply -> runOnUiThread { showBubble(reply) } })
c.sendText("How's my day looking?")
3. iOS — Swift reference client
URLSession for REST + an URLSession.bytes line reader for SSE (no third-party
dependency). Swift Concurrency (async/await).
import Foundation
struct CompanionAck: Decodable { let session: String; let resumeCursor: Int?
let representative: Bool?; let visitorPaired: Bool? }
final class PouchyCompanion {
let baseURL: URL; let token: String; let surface: String
let visitor: (id: String, displayName: String?)? // pass for 代聊 mode
private var session: String?; private var cursor = 0
private var streamTask: Task<Void, Never>?
init(baseURL: String, token: String, surface: String = "ios",
visitor: (id: String, displayName: String?)? = nil) {
self.baseURL = URL(string: baseURL)!; self.token = token
self.surface = surface; self.visitor = visitor
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil) -> URLRequest {
var r = URLRequest(url: baseURL.appendingPathComponent(path))
r.httpMethod = method
r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body { r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.httpBody = try? JSONSerialization.data(withJSONObject: body) }
return r
}
@discardableResult
func connect() async throws -> CompanionAck {
var body: [String: Any] = ["surface": surface]
if let v = visitor {
var vo: [String: Any] = ["id": v.id]; if let n = v.displayName { vo["displayName"] = n }
body["visitor"] = vo
}
let (data, _) = try await URLSession.shared.data(for: request("api/companion/session", method: "POST", body: body))
let ack = try JSONDecoder().decode(CompanionAck.self, from: data)
session = ack.session; cursor = ack.resumeCursor ?? 0
return ack
}
/// Open the auto-reconnecting SSE stream. `onMessage` fires per reply.
func start(onMessage: @escaping (String) -> Void, onError: @escaping (String) -> Void = { _ in }) {
guard let s = session else { return }
streamTask = Task {
while !Task.isCancelled {
do {
let req = request("api/companion/session/\(s)/stream?cursor=\(cursor)")
let (bytes, resp) = try await URLSession.shared.bytes(for: req)
if let code = (resp as? HTTPURLResponse)?.statusCode, code == 401 || code == 403 {
onError("stream unauthorized (\(code))"); return // permanent
}
var event = "message"
for try await line in bytes.lines {
if line.hasPrefix("event:") { event = line.dropFirst(6).trimmingCharacters(in: .whitespaces) }
else if line.hasPrefix("data:") {
let json = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
handle(event: event, data: json, onMessage: onMessage, onError: onError)
}
// blank line = frame boundary; ": ping" comments are ignored
}
} catch { try? await Task.sleep(nanoseconds: 1_500_000_000) } // transient → backoff
}
}
}
private func handle(event: String, data: String, onMessage: (String) -> Void, onError: (String) -> Void) {
guard let obj = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any] else { return }
switch event {
case "reconnect": cursor = obj["cursor"] as? Int ?? cursor // loop reopens with new cursor
case "open": cursor = (obj["payload"] as? [String: Any])?["resumeCursor"] as? Int ?? cursor
case "companion":
let type = obj["type"] as? String
let payload = obj["payload"] as? [String: Any]
if type == "companion.message", let text = payload?["text"] as? String { onMessage(text) }
if type == "control.error", let msg = payload?["message"] as? String { onError(msg) }
default: break
}
}
func stop() { streamTask?.cancel(); streamTask = nil }
func sendText(_ text: String) async throws {
guard let s = session else { return }
_ = try await URLSession.shared.data(for: request("api/companion/session/\(s)/input", method: "POST", body: ["text": text]))
}
}
Note on the
reconnectcursor:URLSession.byteskeeps one connection open; when the server closes at ~45s thefor try await … linesloop ends, thewhilere-enters, and it reopens with the cursor thereconnectframe set. Track the highest cursor seen so a reconnect never replays or skips an event.
4. Matrix 代聊 bridge (the companion answers the user's DMs)
Goal: when someone DMs user A in your Matrix-based IM and A wants their companion to field it, the companion replies on A's behalf — screened (never A's private memory), with per-visitor continuity. This is exactly representative mode; the bridge maps Matrix ⇄ the representative API.
Architecture
Run a small server-side bridge (Node) — always-on, holds each opted-in user's
PAT (with represent + chat + events.subscribe), and a Matrix bot/appservice
identity to read the room and post replies.
Matrix DM room ──(m.room.message from visitor B)──▶ bridge
bridge: representative session (token = A's PAT, visitor = B)
POST /session {visitor:{id:enc(B), displayName:B}} → session
POST /input {text: B's message}
◀ SSE companion.message {text} (A's companion, screened)
bridge ──(post reply as A / as a puppet)──▶ Matrix DM room
Mapping rules
- visitor.id must match
^[A-Za-z0-9_-]{8,64}$. A Matrix MXID (@bob:server.org) isn't url-safe, so encode it stably — e.g. a hex SHA-256, truncated. The same B always maps to the same id → continuous per-visitor thread- memory.
- surface =
matrix:<roomId>— one resumable session per (A, room). - displayName = B's Matrix display name (shown to A's companion).
- One bridge process can multiplex many users; key sessions by
(A's token, surface).
Reference (Node — the JS SDK works server-side)
// npm i @pouchy_ai/companion-sdk matrix-bot-sdk
import { createCompanion } from '@pouchy_ai/companion-sdk';
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from 'matrix-bot-sdk';
import { createHash } from 'node:crypto';
const BASE = 'https://pouchy.ai';
const encVisitor = (mxid: string) => createHash('sha256').update(mxid).digest('hex').slice(0, 48);
// Your app maps a Matrix user → their Pouchy PAT (and whether 代聊 is enabled).
declare function ownerForRoom(roomId: string): Promise<{ ownerMxid: string; pouchyPat: string } | null>;
const matrix = new MatrixClient(HOMESERVER_URL, BOT_ACCESS_TOKEN, new SimpleFsStorageProvider('bridge.json'));
AutojoinRoomsMixin.setupOnClient(matrix);
matrix.on('room.message', async (roomId, ev) => {
const sender = ev.sender as string;
const body = ev.content?.body as string | undefined;
if (!body || ev.content?.msgtype !== 'm.text') return;
const owner = await ownerForRoom(roomId);
if (!owner || sender === owner.ownerMxid) return; // only field messages TO the owner
// Representative session: companion speaks for the owner, to this visitor.
const c = createCompanion({
baseUrl: BASE,
token: owner.pouchyPat, // owner's PAT (holds `represent`)
surface: `matrix:${roomId}`,
appContext: { name: 'YourApp DMs', description: 'Matrix direct message' },
visitor: { id: encVisitor(sender), displayName: (await displayName(matrix, sender)) }
});
await c.connect(); // → { representative: true, … }
// Get the one reply for this turn, post it back, then close.
const reply = await new Promise<string>((resolve) => {
const off = c.onMessage((t) => { off(); resolve(t); });
c.start();
c.sendText(body).catch(() => resolve(''));
});
if (reply) await matrix.sendText(roomId, reply); // post as the bot/owner puppet
c.stop();
});
await matrix.start();
Optional: pairing two Pouchy users
If both A and B use Pouchy, the bridge can pair them so their companions become
friends (unlocking the A2A plane in their own apps). When B hands the bridge their
own PAT (holding social.message):
await c.pairVisitor(visitorPat); // owner PAT holds `represent:pair`
Privacy invariants the bridge inherits (free)
- A's companion only ever reveals screened context to B (persona + safe facts +
— if A granted
expose:knowledge— shared materials). A's system prompt, private memory, intimate facts, and PII are never exposed. - B's messages are quarantined: they don't enter A's personal memory. With
represent:remember, the bridge gets durable per-visitor notes (in an isolated store) so a returning B is remembered. - A token without
representis rejected (403) — the bridge can't accidentally open an owner-facing session against a stranger.
5. Voice (native, WebRTC direct-to-provider)
Pouchy only mints a short-lived credential; the audio is a WebRTC session directly between the device and the voice provider (ElevenLabs or OpenAI) — the Pouchy server is out of the audio path, which is what makes it cheap + low-latency. So the native job is: (1) call the mint endpoint (REST, trivial), then (2) hand the returned creds to the provider's own native path.
5.1 Mint
POST /api/companion/session/{session}/call
body: { voice?, locale? } (locale drives voice + language pick)
→ ElevenLabs: { ok, provider:"elevenlabs-convai", token, agentId, instructions, voice, language, firstMessage }
→ OpenAI: { ok, provider:"openai-realtime", clientSecret, model, voice, expiresAt }
Needs the call scope. The server picks the provider (Convai primary → OpenAI
fallback). In a representative session the instructions are already screened +
visitor-facing — nothing extra to do; you just open the call.
5.2 ElevenLabs Convai (primary — the user's cloned voice)
Use ElevenLabs' native Conversational-AI SDK (Android + iOS, see the ElevenLabs docs) — it owns the mic/WebRTC. Start a session with our minted values mapped to its overrides:
| Mint field | Pass to the EL session as |
|---|---|
token |
the conversation token (connection type: WebRTC) |
instructions |
overrides.agent.prompt.prompt (persona/memory snapshot — the EL agent runs EL's LLM, so it must ride in) |
voice |
overrides.tts.voiceId (the user's cloned voice — don't drop it) |
language |
overrides.agent.language |
firstMessage |
overrides.agent.firstMessage (in-character opener; needs the agent's first-message override enabled) |
Kotlin sketch (mint with the §2 client, then the EL SDK does the audio):
val creds = JSONObject(/* POST .../call response */)
if (creds.getString("provider") == "elevenlabs-convai") {
// ElevenLabs Android Conversational-AI SDK owns mic + WebRTC. Pass:
// conversationToken = creds.getString("token")
// overrides.agent.prompt.prompt = creds.getString("instructions")
// overrides.tts.voiceId = creds.getString("voice")
// overrides.agent.language = creds.optString("language")
// overrides.agent.firstMessage = creds.optString("firstMessage")
// (exact API per the ElevenLabs SDK version you depend on)
}
5.3 OpenAI Realtime (fallback — zero extra SDK, standard WebRTC)
Open a WebRTC peer connection straight to OpenAI with the ephemeral clientSecret.
This is the standard OpenAI Realtime WebRTC handshake — works with any platform
WebRTC lib (Android: org.webrtc / io.getstream:stream-webrtc-android; iOS: the
WebRTC / GoogleWebRTC pod).
1. Create RTCPeerConnection; addTrack(local mic audio).
2. offer = pc.createOffer(); pc.setLocalDescription(offer)
3. POST https://api.openai.com/v1/realtime?model={creds.model}
Authorization: Bearer {creds.clientSecret}
Content-Type: application/sdp
body: offer.sdp
→ 200, body = answer SDP
4. pc.setRemoteDescription({ type:"answer", sdp: answerBody })
5. Audio flows. Use a data channel for events (transcripts, tool calls) if needed.
expiresAt bounds the credential — mint per call. Instructions (screened, in
representative mode) are baked into the session server-side, so nothing to inject.
5.4 Memory after a voice call
The audio never touches Pouchy, so to let the companion remember a call, POST the transcript on close (the device has it from the provider):
POST /api/companion/session/{session}/end body: { transcript: [{role,text}, …] }
For a representative voice call this is quarantined automatically — the
transcript never enters the owner's memory; it distils into per-visitor notes only
under represent:remember.
Deeper background (provider precedence, world-state injection mid-call, the first-party call):
companion-voice-integration.md.
6. Avatar (native, VRM)
GET /api/companion/avatar → the user's current companion look, so you render the
same virtual human Pouchy shows:
GET /api/companion/avatar
→ { ok, name, archetype:"girlfriend"|"boyfriend"|"pet", modelId, vrmUrl, imageUrl }
Any valid token (no special scope). vrmUrl is a VRM model (a glTF 2.0
extension) on a CORS-enabled host; imageUrl is a flat 2D portrait when one exists
(often null for built-ins), useful as a lightweight avatar image / fallback.
Native rendering options, simplest first:
- 2D portrait — if
imageUrl != null, just load it into anImageView/UIImageView. Zero 3D work; good for chat bubbles, lists, call screens. - 3D VRM in a WebView (pragmatic full 3D) — host a tiny page using
@pixiv/three-vrmthat takes?vrm=<vrmUrl>, and drop it in an AndroidWebView/ iOSWKWebView. The/modelsasset is CORS-enabled so the in-WebView fetch works. This reuses the mature web VRM stack and gets you expressions/lip-sync hooks for free. - Native 3D engine — load the glTF/VRM directly: Android with Filament or SceneView; iOS with GLTFKit/SceneKit or RealityKit. VRM-specific features (spring-bone, blendshape presets) need a VRM-aware loader — budget for it, or start with the WebView path.
// REST part is trivial with the §2 client's req():
http.newCall(req("/api/companion/avatar").build()).execute().use { res ->
val a = JSONObject(res.body!!.string())
val vrmUrl = a.optString("vrmUrl") // feed your renderer / WebView
val imageUrl = a.optString("imageUrl", "") // 2D fallback
}
getAvatar reflects the user's live choice (built-in or a custom upload) and falls
back to the archetype default if unset.
7. Notes
- Bluesky / AT Protocol (your social layer) is orthogonal to the companion and
has its own spec — DMs (代聊), 代读/代发, and social-graph→pairing — in
companion-bluesky-bridge.md. Same companion API, a different protocol adapter. - The JS wrapper (
@pouchy_ai/companion-sdk) is the canonical behaviour reference for every REST/SSE flow above — readsrc/lib/companion-sdk/client.tswhen a detail is ambiguous, and the full HTTP contract is incompanion-api-reference.md. - All
/api/companion/*responses are CORS-enabled; the SSE stream is bounded (~45s) by design — reconnect with?cursor=is the normal path, not an error.