Back to home@vINyLogY

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

ComponentLocationRole
bluebubbles-bridgesrc/index.tswebhook route + message injection + 2 send tools + the bluebubbles service
dsh-heartbeatsrc/heartbeat.tsgeneric periodic wake-up (reads heartbeat-targets.json)
dsh-cronsrc/cron.tscron-time tasks (reads cron-jobs.json)
bb-channelbin/bb-channel.mjsCLI: chats/messages/send/bind/contacts/webhook/configure…

Model tools (deliberately only two)

ToolRole
bluebubbles_send_textsend a text message
bluebubbles_send_attachmentsend 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.json directly; 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+):

  1. pendingSent queue: the plugin records (chatGuid, normalized text) before every send; webhook echoes with isFromMe=true matching an entry are dropped (60s TTL, unicode NFC-normalized comparison);
  2. 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

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

PathContentWriter
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.jsonheartbeat targetshand-edited
cron-jobs.jsoncron jobshand-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

  1. Edit src/*.tsnpm run typecheckgit commit
  2. In ~/.dsh/profiles/web/cordis.patch.yml, bump the corresponding row's ?v=N by 1 and save
  3. 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, global fetch/FormData — deliberately .mjs so it runs on any modern Node and stays ESM wherever it is symlinked.
  • Bootstrap: npm install --cache ./.npm-cache && npm run typecheck.