dsh-bluebubbles
Who needs openclaw?
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 22, 2026
- Updated
- Aug 22, 2026
Introduction
dsh-bluebubbles
Bridges a local BlueBubbles server (the macOS iMessage bridge) into DeepSeek Harness.
Design principle (Unix philosophy): the host plugin keeps only passive capabilities (webhook receive + binding resolution + message injection) and two high-frequency model tools (send text / send attachment); everything else converges into the bb-channel CLI — agents call it via bash, humans and automation scripts use it directly.
Components
| Component | Location | Role |
|---|---|---|
bluebubbles-bridge | src/index.ts | webhook route + message injection + 2 send tools + the bluebubbles service |
dsh-heartbeat | src/heartbeat.ts | generic periodic wake-up (reads heartbeat-targets.json) |
dsh-cron | src/cron.ts | cron-time tasks (reads cron-jobs.json) |
bb-channel | bin/bb-channel.mjs | CLI: chats/messages/send/bind/contacts/webhook/configure… |
Model tools (deliberately only two)
| Tool | Role |
|---|---|
bluebubbles_send_text | send a text message |
bluebubbles_send_attachment | send an attachment (image/file) |
Everything else goes through the CLI (agents call it via bash — equivalent capability):
~/.local/bin/bb-channel chats [--limit N] [--all] # list chats (placeholder/pairing-code noise hidden by default)
~/.local/bin/bb-channel messages <chatGuid> [--limit N] # read history (with sender display names)
~/.local/bin/bb-channel send <chatGuid> <text...> # send text
~/.local/bin/bb-channel send-attachment <chatGuid> <file>
~/.local/bin/bb-channel attachment <guid> [--dir D] # download an attachment
~/.local/bin/bb-channel bind <chatGuid> (--workspace PATH | --session ID)
~/.local/bin/bb-channel unbind <chatGuid>
~/.local/bin/bb-channel bindings # show the binding table
~/.local/bin/bb-channel contacts / set-contact <address> <name>
~/.local/bin/bb-channel webhook [--url URL] # check/self-register the webhook
~/.local/bin/bb-channel ping / configure # connectivity / write ~/.dsh/.env
- Output is always pretty JSON (jq-friendly); errors go to stderr with exit 1.
- Credential chain matches the plugin:
process.env→~/.dsh/.env→~/.zshenv; nothing to fill in by hand. - The CLI edits
~/.dsh/bluebubbles-bindings.json/bluebubbles-contacts.jsondirectly; the plugin hot re-reads both files before every inbound message — edits take effect immediately, no reload.
Push path (webhook)
BlueBubbles server (new message)
│ POST {type:"new-message", data:{...}}
▼
DSH webServer route /bluebubbles/webhook (loopback only)
│ hot re-read bindings/contacts → look up chatGuid → workspacePath/sessionId
▼
workspace.sessionIds[0] → agents.get(sessionId).send(userMessage, 'next-step', true)
▼
The workspace's model wakes up and sees a message annotated
"📱 iMessage · <chat name> · 来自 <name> (<number>)"
Anti-loop (two layers, v22+):
pendingSentqueue: the plugin records(chatGuid, normalized text)before every send; webhook echoes withisFromMe=truematching an entry are dropped (60s TTL, unicode NFC-normalized comparison);seenGuids: after a successful send the real guid returned by the API joins a dedup set (BlueBubbles occasionally pushes the same event twice — second-layer backstop).
A blanket isFromMe drop is not an option: phones on the same Apple ID also produce isFromMe=true in a self-chat DM, and dropping all of them would kill real user messages.
Sender display name: payload.handle.displayName → ~/.dsh/bluebubbles-contacts.json (address→name, maintained via bb-channel set-contact) → bare number.
Relay auto-delivery (same mode for inbound and cron)
With relay: true on a binding, the bridge registers a reply trigger for the woken session (persisted to bluebubbles-relay-state.json, 10min TTL, cleared on turn/end; an exact NO_REPLY reply suppresses delivery). During that turn, every assistant message containing text parts is sent back to the chat immediately — thinking and tool results are never delivered.
When dsh-cron fires a job whose target session has a relay: true binding, it arms the same mechanism through the bluebubbles service's armRelay. Cron task prompts must not tell the model to call send tools itself — that would double-send.
Configuration
Credentials
| Method | Takes effect |
|---|---|
bb-channel configure --password <pw> (writes ~/.dsh/.env) | after DSH restart or bridge reload |
env var BLUEBUBBLES_PASSWORD (optionally BLUEBUBBLES_BASE_URL) | after DSH restart |
Never put DSH_-prefixed variables in .env — the DSH bootstrap refuses to start. That is why heartbeat/cron config keys are HEARTBEAT_INTERVAL / HEARTBEAT_TARGETS / CRON_JOBS.
State files ($DSH_HOME, default ~/.dsh)
| Path | Content | Writer |
|---|---|---|
bluebubbles-bindings.json | { "chat:<guid>": { workspacePath | sessionId } } | bb-channel bind/unbind |
bluebubbles-contacts.json | { "address": "display name" } | bb-channel set-contact |
bluebubbles-media/ | inbound attachments (<guid>-<filename>) | bridge auto-download |
heartbeat-targets.json | heartbeat targets | hand-edited |
cron-jobs.json | cron jobs | hand-edited |
Session resolution chain: sessionId direct → otherwise workspacePath → that workspace's sessionIds[0] (most recent session) → verify a live agent. With no live agent the message is dropped and logged (no fallback, no queue).
Updating the code
- Edit
src/*.ts→npm run typecheck→git commit - In
~/.dsh/profiles/web/cordis.patch.yml, bump the corresponding row's?v=Nby 1 and save - In-process HMR does not reliably hot-replace the bridge module (stale fiber routes survive), so restarting DSH is the reliable load path; verify the version marker with
curl -X POST -d '{}' http://127.0.0.1:3080/bluebubbles/webhook(ok-vN).
Diagnostics
With BLUEBUBBLES_DEBUG=1 (env or .env), inbound events and drop reasons go to ~/.dsh/bluebubbles-debug.log (serialized appends, no lost lines).
Security
- The webhook route only accepts loopback sources; BlueBubbles webhooks have no signing mechanism.
- Injected content is plain text messages and triggers no tools; outbound sends always happen through explicit model tool calls.
- Neither the repository nor the patch file contains credentials.
Tech stack
- TypeScript (erasable syntax only), types from
@deepseek-ai/dsh-*devDeps (0.1.0-rc.7 / cordis 4.0.1). - Zero build: Node ≥ 23.6 native type stripping; composition rows point straight at
src/index.ts. - The CLI is plain Node ESM (
bin/bb-channel.mjs), zero dependencies, globalfetch/FormData— deliberately.mjsso it runs on any modern Node and stays ESM wherever it is symlinked. - Bootstrap:
npm install --cache ./.npm-cache && npm run typecheck.