beeper
SkillSearchUnified 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.
No other account needed.
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:
- NO PASSIVE SUMMARIES - Regurgitating conversation content without action items, code, or artifacts is FORBIDDEN
- NO AGREEMENT WITHOUT IMPLEMENTATION - "I agree with X" must be followed by code/file/commit implementing X
- NO RHETORICAL QUESTIONS - Ask only when you cannot proceed without the answer
- 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
| Need | Tier | Why |
|---|---|---|
| Send a text message | MCP | Fast, live write path |
| Send a file/attachment | Desktop API (HTTP) | Upload asset → send message with attachment.uploadID |
| Search recent chats | MCP | Fast, live data |
| List all DMs vs groups | beeper-cli | Has type: "single" field |
| WhatsApp/iMessage chats | beeper-cli | Covers networks MCP misses |
| Full message history | SQLite→DuckDB | Complete archive, no pagination limits |
| Cross-platform analytics | SQLite→DuckDB | JOIN across Signal + iMessage + Telegram |
| Contact resolution | SQLite→DuckDB | m.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
| Tool | Purpose |
|---|---|
search_chats | Search chats by title/network or participants |
get_chat | Get chat metadata (participants, last activity, etc.) |
list_messages | List messages in a chat (paged) |
search_messages | Search messages (literal word match) |
send_message | Send a text message |
focus_app | Open/focus Beeper Desktop and prefill draft text/attachment |
Send Attachments (Programmatic)
MCP send_message is text-only. To send files, use Beeper Desktop API:
POST /v1/assets/upload(multipart) → returnsuploadIDPOST /v1/chats/{chatID}/messageswith JSON body containingattachment.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
--limitflag: 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
| Account | Network | Identity |
|---|---|---|
| hungryserv | Matrix | @zigger:beeper.com (fullName: barton) |
| local-telegram | Telegram | @physetermacrocephalus |
| +14153141554 | ||
| (system) | iMessage | via 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
| Database | Path | Size | Contents |
|---|---|---|---|
| Beeper account.db | ~/Library/Application Support/BeeperTexts/account.db | ~212MB | Signal + Telegram via Matrix protocol |
| Beeper index.db | ~/Library/Application Support/BeeperTexts/index.db | ~641MB | Full-text search index |
| iMessage chat.db | ~/Library/Messages/chat.db | ~43MB | All 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"orquery="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
mcp__beeper__search_chats query="person name"→ get chatIDmcp__beeper__list_messages chatID="..."→ verify identitymcp__beeper__send_message chatID="..." text="..."
Send a File to a Chat
- Get the chatID via
mcp__beeper__search_chats. - 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:
- Check DuckDB first (Tier 3) — zero network cost
- Use MCP for recent (Tier 1) — bounded by limit param
- 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
| 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 |
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
\x22escapes — useregexp_extract, notjson_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