Skip to content

Add Beeper as a third chat ingestion source in Comms, reading from its local Desktop API #5872

Description

@tzioup

Labels to apply (the filing account has read-only access to this repo, so GitHub silently drops them at creation): enhancement, area:life-tracking, plan-feature, needs-decision.

Deliberately not plan — this is a direction question, not claimable work. Please don't let /do:next --issues pick it up before you've answered.

CONTRIBUTING says to open an issue before investing significant time in a new feature area. I've inverted that — the design is already done on a fork and I'd rather throw it away than send you a PR you don't want. So this is the check, late.

Goal

Add Beeper as a third chat ingestion source in Comms, alongside iMessage and Signal, reading from the Beeper Desktop API served locally on 127.0.0.1:23373. One tab at /messages/beeper: conversation list, thread view, participants linked to Tribe people. Read-only in V1.

imessageSync.js and signalSync.js are untouched. Nothing existing is deprecated, replaced, or made conditional. If you turn the feature off, the tree behaves exactly as it does today.

What PortOS already has

I want to be clear I'm not pitching you something you built a year ago. Before writing any of this I read epic #2149 and docs/plans/2026-07-04-human-activity-tracking.md, and the surface is already substantial:

Capability Where
iMessage ingestion — read-only chat.db, node:sqlite, ROWID cursor, hand-rolled typedstream decoder server/services/imessageSync.js
Signal Desktop ingestion — SQLCipher-4 pages + Chromium safeStorage key unwrap in pure node:crypto server/services/signalSync.js, server/lib/signalCrypto.js
Unified machine-local activity store server/services/humanActivity.jshuman_activity_events
Tribe graph with deterministic emails[] / phones[] matching server/lib/tribeMatch.js, server/services/tribe.js
macOS Contacts → handle resolution → Tribe enrichment contactsSync.js, identityResolve.js, tribeContacts.js
Unanswered-thread detection + user-gated outreach drafts server/services/tribeOutreach.js
Comms nav section, ten entries server/lib/navManifest.js:139-150

WhatsApp and Discord are one-shot export importers (whatsappImport.js, discordImport.js); Telegram is an outbound notification bot that ingests nothing into the timeline. That's the gap this aims at, not "PortOS has no messaging."

Why add Beeper anyway

1. Tribe is currently half-blind, and that is the part I actually care about.

Tribe's touchpoint signal comes from iMessage, Signal and Gmail. For most people a large share of real relationship traffic isn't on any of those — it's WhatsApp, Instagram, LinkedIn, Slack, Discord, X. So checkTribeCadence() reports someone overdue when you messaged them yesterday, and findUnansweredTribeThreads() can't see the thread you actually left hanging, because the channel that relationship lives on is invisible to PortOS.

Beeper closes that without a new reader per network. Here is the whole unlock in one table — every network one integration brings in, against what PortOS can do with it today:

Network PortOS today
WhatsApp export file import only
Instagram
Facebook Messenger
Telegram outbound notification bot only
Signal live (SQLCipher reader)
iMessage live (chat.db reader, needs Full Disk Access)
Discord export file import only
Slack
LinkedIn
X
Google Messages (RCS)
Google Chat
Google Voice
Bluesky
LINE
Tumblr DMs

Two of sixteen are live today, and both of those are the ones PortOS maintains a bespoke local-database reader for.

That is the point: Tribe passively holds the fullest available picture of where each relationship stands, across every channel that person actually uses. Cadence stops lying. Unanswered detection covers the conversations where it matters. And every one of those arrives through the same client, the same auth, and the same event stream.

2. The right channel per person, not the one PortOS happens to be able to read.

tribe_people.channel is already a field. Once participants carry network-scoped identities, an outreach draft can be prepared for the route that fits the relationship — LinkedIn for a professional contact, WhatsApp or Instagram for a personal one — instead of defaulting to whichever channel PortOS can currently see. Combined with #2158's timeline-aware prompts, that's the difference between "you haven't spoken to X in 45 days" and a specific, well-grounded heartbeat message ready on the channel X actually replies on.

3. One seam can eventually subsume the readers you already maintain.

This is the direction, stated plainly even though V1 doesn't do it. Beeper bridges iMessage and Signal as well as the fourteen networks PortOS can't reach live at all, so it spans everything both existing readers cover plus everything they don't. So for a user who runs Beeper, one integration could eventually stand in for imessageSync.js's Full Disk Access grant and typedstream decoding and signalSync.js's SQLCipher page decryption with a Keychain-wrapped Chromium key — which its own header calls "the highest-fragility source in the activity-tracking design," re-derived whenever Signal ships a version bump.

More coverage for less maintained surface. V1 proposes none of that — both readers stay, untouched, and a user picks what to enable. But it's the reason I think this is worth building rather than a third scraper.

4. There is no chat-source seam today, and a third source is the reason to build one.

Sources are hard-coded strings:

  • TWO_WAY_CHAT_SOURCES = new Set(['imessage', 'signal'])server/services/tribeOutreach.js:55
  • conversationKey() branches on chatGuid / conversationId / threadId
  • outreachTemplateForSource() picks chat-vs-email by membership of that same set
  • imessageManage.js pins its own SOURCE constant

A third source means either a fourth hard-coded branch or a seam every future source reuses. Push delivery comes with it: iMessage and Signal are interval cursor scans over local SQLite, while Beeper emits real-time domain events over a WebSocket.

5. It's a route to FR-35.

PRD.md FR-35 (Must) requires "a unified messages inbox across email/SMS/iMessage/Signal accounts with triage rules and a review-before-send outbox." Today messageSender.js maps only gmail | outlook | teams, and tribeOutreach.js:573 files every chat draft as sendVia: 'review' because there is nowhere to send it. Beeper has a send API, so the outbox FR-35 asks for becomes reachable for chat channels. Out of scope here (below), but it's the endpoint this is aimed at.

Two things I need you to decide

A. It's a dependency on a third-party account and service

This is the fit question and I'd rather put it first than have you find it.

Beeper Desktop serves the API locally, but the bridges are Beeper's, and self-hosting does not escape that: bbctl connects self-hosted bridges only to beeper.com. Talking to the AGPL mautrix bridges directly isn't a workaround either — on-device bridges aren't connected to the homeserver, so such a client would silently omit whole networks. Every bridge on the install I tested is a cloud provider.

It isn't a paid dependency. Beeper is free to use and I've never paid for it. Paid tiers exist; nothing in this proposal needs one. The free tier currently caps a new user at five accounts, one per network, which is the real constraint to design against — one network has to work as well as nine.

What it does require is a Beeper account and the client reachable locally. A headless Beeper Server is documented on the same port with the same surface, so I'd probe a configurable base URL via GET /v1/info rather than assuming Beeper Desktop specifically is running.

For a local-first app the account requirement may still be disqualifying, and that's a reasonable answer. It's why the feature would ship defaultEnabled: false behind a manual toggle.

B. Mirroring full message bodies departs from a contract you enforce in code

Your stance isn't just in the design doc. server/services/humanActivity.js:36 carries:

// contract: we keep a preview, never the full body.
export function shortSummary(text, max = 160) {  }

My design mirrors full Beeper message bodies into Postgres. The argument for it is that the three assumptions behind the iMessage doctrine don't hold here:

  • No backfill before the bridge. Beeper retains history from the moment a network is connected onward, but it does not reach back before that. So depth varies per network by when each bridge was added, and a Discord channel joined last week genuinely has no earlier history to give. The durable archive starts the day the bridge does — which is exactly the window a local mirror preserves and a read-through design doesn't.
  • Not queryable. List endpoints have no limit and expose only opaque cursors. There is no "every message from this person across every network since March" — which is the query Tribe relation and search both need.
  • Not under the user's control. It's a third-party client that can be uninstalled, an account that can be closed, and an API that can change. Read-through means every PortOS feature built on this data stops the day any of those happens.

The counter-argument is real and I'll state it: this puts the plaintext of every bridged conversation in a local Postgres, which is the most sensitive payload PortOS would hold. It's also worth saying that when I wrote the ADR for this I justified mirroring on three grounds — writing-style analysis, Tribe relation, and search — and I've since ruled the first out of V1. So the case is thinner now than when I made it.

The alternative is conforming to shortSummary like every other source, keeping a ≤160-char preview with a pointer back to Beeper. That loses full-text search and offline reading, and means the thread view can't render without a live API call. I'd build it either way — I just don't want to guess which one you'd accept.

Proposed shape

  • Optional instance feature. A beeper entry in server/lib/instanceFeatureRegistry.js, defaultEnabled: false, and deliberately no detector — a token-presence gate can't bootstrap the very screen that sets the token. Nav entry /messages/beeper tagged feature: 'beeper', with the matching Layout.jsx tag so navManifest.test.js stays happy. This would be the first feature-gated page in Comms; there's no SECTION_FEATURE entry for that section today and I'm not proposing one.
  • Two flags, kept distinct. The instance feature governs navigation; settings.beeper.enabled governs ingestion. Turning the nav off must not silently stop the sweep.
  • Storage db-primary per docs/STORAGE.md — relational, needs search, carries sync cursors. Tables beeper_conversations, beeper_messages, beeper_participants, beeper_attachments, beeper_sync_cursors, beeper_accounts, behind a tracked migration in scripts/migrations/.
  • Never federates. No PEER_SUBSCRIBABLE_KINDS entry, no dataSync category, no PORTOS_SCHEMA_VERSIONS entry, no sync_sequence column, and data/beeper/ absent from MEDIA_LIBRARY_KIND_DIRNAMES. Enforced by a guard test modelled on privacyNeverFederates.test.js, with a planted-violation probe, not by comment. The inbound source tombstone is named unsent_at specifically so it doesn't collide with the federation guard's column list.
  • Token vaulted. OAuth with dynamic client registration and PKCE S256 (the authorization server advertises both), encrypted with vaultCrypto.js into Postgres. Pasting a token stays first-class, because Beeper's own UI can mint a no-expiry token and no OAuth surface accepts a lifetime. There is no refresh grant, so expiry is stored beside the token and surfaced at seven days.
  • Correctness from HTTP, promptness from the socket. The WebSocket is at-most-once with no replay on reconnect, so an interval sweep runs unconditionally and the socket only makes it fast. The relay carries invalidation only, never rowspeerSocketRelay.js opens a Socket.IO client to every online peer, so a global emit would cross installs.
  • tribe_identities for network-scoped handles. A live census found email never populated on any network for one-to-one chats; roughly 81% of counterparties carry a phone, 6% a username and no phone, and 13% no durable identifier at all (Facebook and Slack hand over an opaque id and a display name). emails[] / phones[] match none of a WhatsApp JID, a Discord snowflake, or an X handle. So: a child table keyed (kind, network, handle), with beeper_participants.tribe_person_id as a cache authoritative only for that 13%. Group chats relate by message sender only — participant lists truncate at 100 with no participants endpoint and no cursor, so a roster is always a subset.

Delivery slices

Ten, in dependency order. The first two and the ninth are the ones I'd want reviewed hardest.

  1. Schema and migration for the six tables.
  2. Guard test proving no beeper_* table or directory federates.
  3. HTTP client — cursor pagination (GET /v1/chats has no limit; passing one is silently ignored and under-counts), retries off for sends, and 502-from-assets mapped as terminal rather than transient.
  4. Feature gate, settings, nav entry, status card. Status is three fields — tokenConfigured, reachable: true|false|null, lastProbeError — never collapsed into one boolean.
  5. OAuth connect flow with the paste-a-token alternative and vaulted storage.
  6. Ingestion sweep with the cursor and rows committing in one transaction.
  7. WebSocket transport, 75-second silence watchdog, invalidation-only relay.
  8. Tribe network-scoped identities and inline participant linking.
  9. Chat surface — conversation list, thread, participants. Selection in the URL.
  10. Lazy attachment mirror with a disk budget and an eviction guard.

Acceptance criteria

  • With the feature off, ⌘K and the sidebar don't show the page, the <Route> still resolves, and no scheduler is registered or logged.
  • A fresh install with no Beeper account can enable the feature, see an honest "not connected" state, and never sees an error.
  • The guard test fails when a beeper_* table with a sync_sequence column is planted, and when data/beeper/ is added to the media-federation allowlist.
  • A sweep interrupted before commit leaves the cursor unmoved.
  • A killed socket reconnects with jitter and triggers a bounded sweep; no Beeper payload ever reaches ioInstance.emit.
  • A counterparty with no durable identifier can be linked by hand and survives a re-sync.
  • Deep-linking a conversation URL opens it on a cold load.
  • The token value appears in no log, no error, and no client response — asserted by a test.
  • No fixture contains a real handle, name, phone number, or message body.

Out of scope

  • Sending. Beeper can send, and FR-35 wants an outbox that covers chat, but graduating a chat source from sendVia: 'review' to a real transport is its own argument and its own risk review. Read-first here; I'd raise send separately if you want it at all. Worth flagging honestly: on my own fork I'd concluded read-only doesn't meet my goal, so this staging is a concession to reviewability, not the original design.
  • Writing-style analysis over message text, and an activity-timeline adapter reading beeper_messages alongside human_activity_events. Both ruled post-V1. Nothing in V1 depends on either; daily Tribe touchpoints are still written.
  • Replacing or deprecating imessageSync.js / signalSync.js. They stay, untouched, and the user picks what to enable. Reason 3 above is the direction I think this eventually points, but it isn't what I'm asking for here and it shouldn't be a condition of accepting V1.
  • Cross-source dedupe. Beeper also bridges Signal, so a user running both sees the same conversation twice. V1 builds no exclusion or dedupe machinery and documents that instead. If you'd want dedupe as a condition of acceptance, say so — it changes the schema.
  • Self-hosting the Matrix stack, or talking to bridges directly as a Matrix client. Both confirmed dead ends above.
  • Outbound attachments, reactions, read receipts, typing indicators, and merged cross-network conversation views.

Prior work

There's a fork with the design already worked through, offered as evidence rather than as a claim on your time — I'm not asking you to review it:

  • tzioup/PortOS#1 — the map, with the decision record and the dependency table.
  • Eleven implementation issues, #27#37, decision-complete.
  • Four research notes on branch research/beeper, and a fixtures-driven UI prototype on prototype/beeper-chat-surface.
  • An ADR on branch docs/beeper-store for the body-mirroring decision. Read it with a caveat — it's stale on two points I later changed (the source tombstone now keeps body and bytes and stamps unsent_at, and the never-evict rule now carves out attachment bytes). The position as stated above is the current one.

Several claims in those notes were research-grade inference that a live probe later overturned, so anything load-bearing above was verified against the running API rather than inferred.

Along the way I found nine unrelated defects and doc corrections in this repo, four of them good first issue-shaped. I'll file those separately regardless of what you decide here — they stand on their own.


Happy to hear "no" or "yes but summary-only" or "not with a cloud dependency." Any of those is a better outcome than a large PR you didn't want.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:life-trackingHuman activity timeline, ingestion sources & life-logplanTracked by /do:replan

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions