beeper

SkillSearch

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the beeper skill

What this skill tells your AI

The instructions your AI receives, as published by plurigrid/asi in skills/beeper/SKILL.md and read by ahel’s review.

CRITICAL: TOKENS PAY RENT

Every output token must produce actionable value. Violations:

  1. NO PASSIVE SUMMARIES - Regurgitating conversation content without action items, code, or artifacts is FORBIDDEN
  2. NO AGREEMENT WITHOUT IMPLEMENTATION - "I agree with X" must be followed by code/file/commit implementing X
  3. NO RHETORICAL QUESTIONS - Ask only when you cannot proceed without the answer
  4. NO PRAISE/VALIDATION - Skip "great question" / "you're right" - proceed to work

When reviewing message history:

  • Extract ACTION ITEMS → create files, send messages, write code
  • Extract DECISIONS → update configs, create artifacts documenting the decision
  • Extract BLOCKERS → file issues, send follow-up messages
  • NEVER just summarize what was discussed

Enforcement: If output contains summary without artifact, STOP and create the artifact first.

Beeper Unified Messaging Skill

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

When to Use Which Tier

NeedTierWhy
Send a text messageMCPFast, live write path
Send a file/attachmentDesktop API (HTTP)Upload asset → send message with attachment.uploadID
Search recent chatsMCPFast, live data
List all DMs vs groupsbeeper-cliHas type: "single" field
WhatsApp/iMessage chatsbeeper-cliCovers networks MCP misses
Full message historySQLite→DuckDBComplete archive, no pagination limits
Cross-platform analyticsSQLite→DuckDBJOIN across Signal + iMessage + Telegram
Contact resolutionSQLite→DuckDBm.room.member events have display names

Tier 1: Desktop API (MCP + HTTP)

# Search for a chat
mcp__beeper__search_chats query="contact name"

# Get chat metadata
mcp__beeper__get_chat chatID="..."

# List messages from a chat
mcp__beeper__list_messages chatID="..."

# Search message content (literal word match; limit <= 20)
mcp__beeper__search_messages query="keyword" limit=20

# Send a text message
mcp__beeper__send_message chatID="..." text="Hello!"

# Prefill a draft with an attachment (manual send fallback)
mcp__beeper__focus_app chatID="..." draftText="..." draftAttachmentPath="/path/to/file"

MCP Tools

ToolPurpose
search_chatsSearch chats by title/network or participants
get_chatGet chat metadata (participants, last activity, etc.)
list_messagesList messages in a chat (paged)
search_messagesSearch messages (literal word match)
send_messageSend a text message
focus_appOpen/focus Beeper Desktop and prefill draft text/attachment

Send Attachments (Programmatic)

MCP send_message is text-only. To send files, use Beeper Desktop API:

  1. POST /v1/assets/upload (multipart) → returns uploadID
  2. POST /v1/chats/{chatID}/messages with JSON body containing attachment.uploadID

Wrapper script (recommended): scripts/beeper_send_file.sh

skills/beeper/scripts/beeper_send_file.sh '<chat_id>' /path/to/file 'optional text'

If curl to http://localhost:23373 fails in a sandbox, rerun with escalated permissions.

Tier 2: beeper-cli (Authenticated CLI)

Requires fnox for auth. Assumes beeper-cli is available in PATH.

# Verify beeper-cli is available
command -v beeper-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" — more reliable than MCP)
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli chats list -o json

# List messages from a chat
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli messages list -o json --chat-id "..."

# List connected accounts (Signal, Telegram, WhatsApp, iMessage)
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli accounts list -o json

# Filter by account
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli chats list -o json --account-ids whatsapp

# Pagination (cursor-based)
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli chats list -o json --cursor "..." --direction before

beeper-cli Caveats

  • Pagination bug: cursor can cycle — always deduplicate on chat ID
  • No --limit flag: uses cursor pagination only
  • iMessage coverage: shows ~22 recent chats; chat.db has 354 DMs
  • WhatsApp: separate account, must use --account-ids whatsapp

Connected Accounts

AccountNetworkIdentity
hungryservMatrix@zigger:beeper.com (fullName: barton)
local-telegramTelegram@physetermacrocephalus
whatsappWhatsApp+14153141554
(system)iMessagevia macOS bridge

Tier 3: SQLite→DuckDB (Full Archive)

Direct database access for complete offline analysis. DuckDB attaches SQLite databases inline — no ETL needed.

Database Locations

DatabasePathSizeContents
Beeper account.db~/Library/Application Support/BeeperTexts/account.db~212MBSignal + Telegram via Matrix protocol
Beeper index.db~/Library/Application Support/BeeperTexts/index.db~641MBFull-text search index
iMessage chat.db~/Library/Messages/chat.db~43MBAll iMessage/SMS history

DuckDB Inline Attach Pattern

INSTALL sqlite;
LOAD sqlite;

-- Attach both databases read-only
ATTACH '~/Library/Application Support/BeeperTexts/account.db'
  AS beeper (TYPE sqlite, READ_ONLY);
ATTACH '~/Library/Messages/chat.db'
  AS imessage (TYPE sqlite, READ_ONLY);

-- Now query across both with standard SQL
SELECT * FROM beeper.local_events LIMIT 1;
SELECT * FROM imessage.chat LIMIT 1;

Unified DM Landscape Query

The canonical cross-platform DM analysis query:

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);

WITH
-- Beeper Signal + Telegram DMs (3+ user messages, ≤2 senders = DM)
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
),

-- Resolve display names via regex on BLOB content
-- (BLOBs use \x22 escapes instead of standard JSON quotes)
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 (style != 43 excludes groups)
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 DM Landscape Table

Results are materialized in ~/i.duckdb as dm_landscape:

-- Quick summary
duckdb ~/i.duckdb -c "
SELECT network, COUNT(*) AS threads, SUM(my_msg_count) AS total_my_msgs
FROM dm_landscape GROUP BY ALL ORDER BY threads DESC;
"
-- 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 (Matrix protocol):

room_id TEXT, event_id TEXT, sender TEXT, type TEXT,
state_key TEXT, content BLOB, redacts TEXT, unsigned BLOB,
stream_order INT, event_ts INT, created_ts INT

Key event types:

  • m.room.message — chat messages (sender, content with body)
  • m.room.member — join/leave (content has displayname, membership)
  • m.room.name — room name changes

Content BLOB quirk: 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).

Search Guidelines

MCP queries are LITERAL WORD MATCHING, not semantic search.

  • RIGHT: query="dinner" or query="flight"
  • WRONG: query="dinner plans tonight"

Multiple words = ALL must match. Use single keywords.

For semantic/complex searches, use Tier 3 (DuckDB full-text search on SQLite archive).

Tier Selection Decision Tree

Need to SEND something?
  └─ Text only → Tier 1 (MCP)
  └─ File/attachment → Tier 1 (Desktop API HTTP: upload + send; `scripts/beeper_send_file.sh`)

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)

Workflow Patterns

Find and Message Someone

  1. mcp__beeper__search_chats query="person name" → get chatID
  2. mcp__beeper__list_messages chatID="..." → verify identity
  3. mcp__beeper__send_message chatID="..." text="..."

Send a File to a Chat

  1. Get the chatID via mcp__beeper__search_chats.
  2. Send the file via scripts/beeper_send_file.sh.

DM Landscape Analysis (Tier 3)

duckdb ~/i.duckdb -c "
SELECT contact_name, my_msg_count, network
FROM dm_landscape
ORDER BY my_msg_count DESC LIMIT 20;
"

Cross-Platform Contact Lookup (Tier 3)

-- Find someone across Signal + iMessage + Telegram
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);

-- Search Beeper (Signal/Telegram)
SELECT DISTINCT
    regexp_extract(CAST(content AS VARCHAR),
        'displayname\\x22:\\x22([^\\]+)', 1) AS name,
    room_id
FROM beeper.local_events
WHERE type = 'm.room.member'
  AND CAST(content AS VARCHAR) LIKE '%SearchName%';

-- Search iMessage
SELECT chat_identifier, display_name
FROM imessage.chat
WHERE display_name LIKE '%SearchName%'
   OR chat_identifier LIKE '%SearchName%';

Filter by Network (Tier 2)

# Get account IDs
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli accounts list -o json

# Filter chats to WhatsApp only
BEEPER_ACCESS_TOKEN=$(fnox get BEEPER_ACCESS_TOKEN --age-key-file ~/.age/key.txt) \
  beeper-cli chats list -o json --account-ids whatsapp

Refresh dm_landscape Table

duckdb ~/i.duckdb -c "
INSTALL sqlite; LOAD sqlite;
ATTACH '\$HOME/Library/Application Support/BeeperTexts/account.db' AS beeper (TYPE sqlite, READ_ONLY);
ATTACH '\$HOME/Library/Messages/chat.db' AS imessage (TYPE sqlite, READ_ONLY);

CREATE OR REPLACE TABLE dm_landscape AS
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;

SELECT network, COUNT(*) AS threads, SUM(my_msg_count) AS total_my_msgs
FROM dm_landscape GROUP BY ALL ORDER BY threads DESC;
"

Resource-Aware Processing

NEVER pull full message history into context. Tier hierarchy:

  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

CONTEXT_BUDGET = 10000 chars
Always set limit param on MCP calls.
Always use dateAfter on search_message_contents.
Prefer DuckDB queries over fetching raw messages.

GF(3) Triadic Access Pattern

TritRoleTierAction
MINUS (-1)ValidatorSQLite→DuckDBVerify data exists locally before fetching
ERGODIC (0)Coordinatorbeeper-cliMetadata, routing, account selection
PLUS (+1)GeneratorMCPSend messages, fetch fresh data

Conservation: all three tiers complement without overlap. Desktop API writes (MCP + HTTP), beeper-cli routes, DuckDB archives.

Known Issues

  • beeper-cli pagination cycles: cursor-based pagination can return duplicates; always deduplicate on chat ID
  • Beeper BLOB encoding: content column uses \x22 escapes — use regexp_extract, not json_extract_string
  • iMessage chat.db SQL: use <> not != (shell escaping issue)
  • WhatsApp in account.db: WhatsApp bridge stores data differently; not present in account.db local_events — use beeper-cli for WhatsApp

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.

Signals

GitHub stars
63
Forks
12
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
beeper
Source
github.com/plurigrid/asi