Agent Skills: Beeper Unified Messaging

Unified messaging via three access tiers — MCP (live API), beeper-cli (authenticated CLI), and direct SQLite→DuckDB (full archive). Search, analyze, and act across all networks. Subsumes beeper-mcp, messaging-world, and signal-messaging.

UncategorizedID: plurigrid/asi/beeper

Install this agent skill to your local

pnpm dlx add-skill https://github.com/plurigrid/asi/tree/HEAD/plugins/asi/skills/beeper

Skill Files

Browse the full folder contents for beeper.

Download Skill

Loading file tree…

plugins/asi/skills/beeper/SKILL.md

Skill Metadata

Name
beeper
Description
Unified messaging via three access tiers — MCP (live API), beeper-cli (authenticated CLI), and direct SQLite→DuckDB (full archive). Search, analyze, and act across all networks. Subsumes beeper-mcp, messaging-world, and signal-messaging.

CRITICAL: TOKENS PAY RENT

Every output token must produce actionable value.

  1. NO PASSIVE SUMMARIES — extract ACTION ITEMS, DECISIONS, BLOCKERS → create artifacts
  2. NO AGREEMENT WITHOUT IMPLEMENTATION — "I agree" must be followed by code/file/commit
  3. NO RHETORICAL QUESTIONS — ask only when you cannot proceed without the answer
  4. Enforcement: If output contains summary without artifact, STOP and create the artifact first.

Beeper Unified Messaging

Access all messaging networks through three access tiers with increasing depth.

Three Access Tiers

Tier 1: Desktop API (MCP + HTTP)  — real-time chat; send text + attachments
Tier 2: beeper-cli (Auth)         — paginated history, chat type metadata, contacts
Tier 3: SQLite→DuckDB (Archive)   — full offline archive, cross-platform analytics

Tier Selection Decision Tree

Need to SEND something?
  └─ Text only → Tier 1 (MCP send_message)
  └─ File/attachment → Tier 1 (Desktop API HTTP: upload + send)

Need chat type (DM vs group)?
  └─ Yes → Tier 2 (beeper-cli has type: "single"|"group")

Need full history or cross-platform JOIN?
  └─ Yes → Tier 3 (SQLite→DuckDB)

Need real-time / recent?
  └─ Yes → Tier 1 (MCP) or Tier 2 (beeper-cli)

Need contact name resolution?
  └─ MCP has senderName in messages
  └─ beeper-cli has title field on chats
  └─ SQLite has m.room.member displayname (most complete)

Tier 1: Desktop API (MCP + HTTP)

MCP Tools

| Tool | Purpose | |------|---------| | search_chats | Search chats by title/network or participants | | get_chat | Get chat metadata (participants, last activity) | | list_messages | List messages in a chat (paged) | | search_messages | Search messages (literal word match, limit ≤ 20) | | send_message | Send a text message | | focus_app | Open Beeper Desktop, prefill draft text/attachment | | archive_chat | Archive/unarchive a chat | | set_chat_reminder | Set reminder for a chat |

mcp__beeper__search_chats query="contact name"
mcp__beeper__list_messages chatID="..."
mcp__beeper__send_message chatID="..." text="Hello!"
mcp__beeper__focus_app chatID="..." draftText="..." draftAttachmentPath="/path/to/file"

Search is LITERAL WORD MATCHING, not semantic. Use single keywords.

Send Attachments (Programmatic)

MCP send_message is text-only. For files:

  1. POST /v1/assets/upload (multipart) → returns uploadID
  2. POST /v1/chats/{chatID}/messages with attachment.uploadID
skills/beeper/scripts/beeper_send_file.sh '<chat_id>' /path/to/file 'optional text'

User Identity

Beeper/Matrix has TWO identifiers per user:

  • Matrix userID: @username:beeper.com (permanent)
  • Display name: User-chosen (can differ)

Cross-reference list_messages to map senderIDsenderName.


Tier 2: beeper-cli (Authenticated CLI)

# Auth pattern — secret never exposed to context
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli <command> -o json

# List chats (has type: "single" vs "group")
beeper-cli chats list -o json

# List messages from a chat
beeper-cli messages list -o json --chat-id "..."

# List connected accounts
beeper-cli accounts list -o json

# Filter by account (e.g. WhatsApp)
beeper-cli chats list -o json --account-ids whatsapp

Connected Accounts

| Account | Network | Identity | |---------|---------|----------| | hungryserv | Matrix | @zigger:beeper.com | | local-telegram | Telegram | @physetermacrocephalus | | whatsapp | WhatsApp | +14153141554 | | (system) | iMessage | via macOS bridge |

Caveats

  • Pagination bug: cursor can cycle — always deduplicate on chat ID
  • iMessage coverage: ~22 recent chats; chat.db has 354 DMs
  • WhatsApp: must use --account-ids whatsapp

Tier 3: SQLite→DuckDB (Full Archive)

Database Locations

| Database | Path | Contents | |----------|------|----------| | Beeper account.db | ~/Library/Application Support/BeeperTexts/account.db | Signal + Telegram via Matrix | | Beeper index.db | ~/Library/Application Support/BeeperTexts/index.db | Full-text search index | | iMessage chat.db | ~/Library/Messages/chat.db | All iMessage/SMS history |

DuckDB Inline Attach

INSTALL sqlite; LOAD sqlite;
ATTACH '~/Library/Application Support/BeeperTexts/account.db'
  AS beeper (TYPE sqlite, READ_ONLY);
ATTACH '~/Library/Messages/chat.db'
  AS imessage (TYPE sqlite, READ_ONLY);

Unified DM Landscape Query

WITH
beeper_dms AS (
    SELECT le.room_id,
        COUNT(*) FILTER (WHERE le.sender = '@zigger:beeper.com') AS my_msg_count,
        COUNT(DISTINCT le.sender) AS sender_count,
        CASE WHEN le.room_id LIKE '%.local-signal.%' THEN 'Signal'
             WHEN le.room_id LIKE '%.local-telegram.%' THEN 'Telegram'
             ELSE 'Other' END AS network
    FROM beeper.local_events le WHERE le.type = 'm.room.message'
    GROUP BY le.room_id
    HAVING COUNT(*) FILTER (WHERE le.sender = '@zigger:beeper.com') >= 3
       AND COUNT(DISTINCT le.sender) <= 2
),
beeper_names AS (
    SELECT DISTINCT ON (le.room_id) le.room_id,
        regexp_extract(CAST(le.content AS VARCHAR),
            'displayname\\x22:\\x22([^\\]+)', 1) AS display_name
    FROM beeper.local_events le
    WHERE le.type = 'm.room.member'
      AND le.sender <> '@zigger:beeper.com'
      AND le.state_key <> '@zigger:beeper.com'
      AND CAST(le.content AS VARCHAR) NOT LIKE '%bridge bot%'
    ORDER BY le.room_id, le.event_ts DESC
),
beeper_final AS (
    SELECT bd.room_id AS id,
        COALESCE(NULLIF(bn.display_name, ''), 'unnamed') AS contact_name,
        bd.my_msg_count, bd.network, 'beeper_db' AS source
    FROM beeper_dms bd LEFT JOIN beeper_names bn ON bd.room_id = bn.room_id
),
imessage_dms AS (
    SELECT c.chat_identifier AS id,
        COALESCE(NULLIF(c.display_name, ''), c.chat_identifier) AS contact_name,
        COUNT(*) AS my_msg_count, 'iMessage' AS network, 'imessage_db' AS source
    FROM imessage.chat c
    JOIN imessage.chat_message_join cmj ON c.ROWID = cmj.chat_id
    JOIN imessage.message m ON cmj.message_id = m.ROWID
    WHERE m.is_from_me = 1 AND c.style <> 43
    GROUP BY c.ROWID, c.chat_identifier, c.display_name HAVING COUNT(*) >= 3
)
SELECT * FROM beeper_final UNION ALL SELECT * FROM imessage_dms
ORDER BY my_msg_count DESC;

Persisted Table

Results materialized in ~/i.duckdb as dm_landscape:

  • Signal: 232 threads (8,569 msgs)
  • iMessage: 119 threads (9,192 msgs)
  • Telegram: 9 threads (156 msgs)
  • Total: 360 threads, 17,917 messages

Schema Reference

beeper.local_events: room_id, event_id, sender, type, state_key, content BLOB, event_ts INT

  • Content BLOB uses \x22 instead of " — use regexp_extract not json_extract_string

imessage.chat: ROWID, chat_identifier, display_name, style (43=group) imessage.message: ROWID, text, is_from_me, date, handle_id Join via imessage.chat_message_join(chat_id, message_id).


ACSet Social Graph Schema

@present SchMessagingWorld(FreeSchema) begin
  Identity::Ob; Channel::Ob; Account::Ob; Contact::Ob; Conversation::Ob; Network::Ob
  identity_account::Hom(Account, Identity)
  account_channel::Hom(Account, Channel)
  contact_account::Hom(Contact, Account)
  conv_account::Hom(Conversation, Account)
  contact_network::Hom(Contact, Network)
  conv_network::Hom(Conversation, Network)
  Email::AttrType; Name::AttrType; Trit::AttrType; Count::AttrType
  contact_email::Attr(Contact, Email)
  contact_name::Attr(Contact, Name)
  channel_trit::Attr(Channel, Trit)
  contact_count::Attr(Contact, Count)
end

Channel Trit Assignment

OUTLOOK (−1)  →  y.shkel@utoronto.ca    (academic, validator)
GMAIL   ( 0)  →  greenteatree01@gmail   (personal, coordinator)
BEEPER  (+1)  →  @greenteatree01:beeper (multi-protocol, generator)
Σ = 0 ✓

Network Topology (50 Active Chats)

  • Signal (18): ies, Meta-Org, Cognition Cosmos, Gay.jl, sloppies, Avalon, NYCies + DMs
  • Telegram (15): Frontier Tower community channels
  • WhatsApp (6): Stanford BJJ, family, salon
  • Discord (5): string-parsing-mines, 7-calendar-years + DMs
  • Matrix (3): Beeper Dev Community, Vivarium Public

Conversation Branch Tracking

CREATE TABLE IF NOT EXISTS beeper_conversation_branches (
  branch_id VARCHAR PRIMARY KEY,
  chat_id VARCHAR NOT NULL,
  parent_branch_id VARCHAR,
  topic VARCHAR NOT NULL,
  first_message_id VARCHAR,
  last_message_id VARCHAR,
  status VARCHAR DEFAULT 'open',  -- 'open', 'resolved', 'merged', 'stale'
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  resolved_at TIMESTAMP
);

CREATE TABLE IF NOT EXISTS beeper_branch_transitions (
  from_branch VARCHAR,
  to_branch VARCHAR,
  transition_type VARCHAR,  -- 'fork', 'merge', 'abandon', 'resolve'
  message_id VARCHAR,
  timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (from_branch, to_branch, message_id)
);

Branch rules: Fork (one message → multiple topics), Merge (response addresses multiple), Resolve (explicit closure), Abandon (7 days inactive → stale).


Signal via Rust MCP (Direct Channel)

For Signal-only access without Beeper bridge:

{
  "signal": {
    "command": "cargo",
    "args": ["run", "--release", "--example", "signal-server-stdio"],
    "cwd": "/Users/alice/signal-mcp",
    "env": { "RUST_LOG": "signal_mcp=info" }
  }
}

Capabilities: send/receive messages, list conversations, handle attachments. Use read_mcp_resource with signal:// URIs.


GF(3) Triadic Access Pattern

| Trit | Role | Tier | Action | |------|------|------|--------| | MINUS (−1) | Validator | SQLite→DuckDB | Verify data exists locally before fetching | | ERGODIC (0) | Coordinator | beeper-cli | Metadata, routing, account selection | | PLUS (+1) | Generator | MCP | Send messages, fetch fresh data |


Resource-Aware Processing

NEVER pull full message history into context.

  1. Check DuckDB first (Tier 3) — zero network cost
  2. Use MCP for recent (Tier 1) — bounded by limit param
  3. Use beeper-cli for metadata (Tier 2) — chat types, accounts

Context budget: 10,000 chars. Always set limit and dateAfter params.


MCP Server Config

{
  "beeper": {
    "command": "/bin/sh",
    "args": ["-c", "BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) exec npx -y @beeper/desktop-mcp"]
  }
}

Requires: fnox, age key at ~/.age/key.txt, npx in PATH, Beeper Desktop running.


Known Issues

  • beeper-cli pagination cycles: cursor can loop — deduplicate on chat ID
  • Beeper BLOB encoding: \x22 escapes — use regexp_extract
  • WhatsApp not in account.db: use beeper-cli for WhatsApp
  • iMessage SQL: use <> not != (shell escaping)