.agents/skills/local-testing/bot/imessage/index.md
The iMessage channel is different from the other bot platforms: there is no native app to drive with osascript. Instead the Desktop app runs a local BlueBubbles bridge — a small HTTP server in the Electron main process that registers a webhook on a local BlueBubbles server, receives iMessage events, and forwards them to LobeHub Cloud.
So the test surface is three layers:
imessageBridge.* handlers (getStatus,
testConfig, upsertConfig, removeConfig, start, stop)http://127.0.0.1:<port>/webhooks/bluebubbles/<appId>?secret=<secret>http://127.0.0.1:1234/api/v1/* (webhook + server/info)http://127.0.0.1:1234) with
a known password. Sanity check:
curl -sS -m4 -o /dev/null -w '%{http_code}\n' \
"http://127.0.0.1:1234/api/v1/server/info?password=<PW>" # expect 200
./.agents/skills/local-testing/scripts/electron-dev.sh startimessageBridge IPC group
and @lobechat/chat-adapter-imessage must be compiled into the main bundle).
Run pnpm install --ignore-scripts at the repo root and in apps/desktop/
after switching branches — the new workspace package must be linked or the
main build fails to resolve @lobechat/chat-adapter-imessage../.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
Asserts the whole flow and self-cleans (unique applicationId per run, removes
its bridge config + BlueBubbles webhook on exit). Exit 0 = all green. It covers:
testConfig happy path → successtestConfig wrong password → rejected; unreachable URL → rejectedupsertConfig first-time save → success (Bug #1 regression guard, below)getStatus → running:true, config persisted, password redacted (blueBubblesPasswordSet)The password is passed as argv (visible in ps) — local dev only, don't use a
real secret on a shared machine.
The renderer exposes the main-process handlers via window.electronAPI.invoke.
This is the quickest way to exercise the bridge without clicking:
# baseline
agent-browser --cdp 9222 eval \
"(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
# test a connection (note: password as a JS string)
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(async function () {
try {
var r = await window.electronAPI.invoke('imessageBridge.testConfig', {
applicationId: 'probe',
blueBubblesServerUrl: 'http://127.0.0.1:1234',
blueBubblesPassword: 'PASTE_PW',
enabled: true,
webhookSecret: 'probe-secret',
});
return JSON.stringify(r); // { success: true }
} catch (e) { return 'ERR: ' + (e.message || e); }
})()
EVALEOF
upsertConfig persists to the Electron store, starts the local HTTP server, and
registers the BlueBubbles webhook. removeConfig + stop reverse it.
The bridge settings only render in Desktop (isDesktop guard) under the agent's
Channel → iMessage screen. The platform tile only appears as a real (non
"Coming Soon") entry once the server registers imessage and the frontend
drops it from COMING_SOON_PLATFORMS (src/routes/(main)/agent/channel/const.ts).
agent-browser --cdp 9222 open "http://localhost:5173/agent/<aid>/channel"
agent-browser --cdp 9222 wait --load networkidle && agent-browser --cdp 9222 wait 1500
# confirm the remote backend lists imessage (it must be registered + deployed)
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(async function(){
var url='lobe-backend://lobe/trpc/lambda/agentBotProvider.listPlatforms?input='+encodeURIComponent('{"json":null,"meta":{"values":["undefined"],"v":1}}');
var d=await (await fetch(url,{credentials:'include'})).json();
var p=d.result?.data?.json||d;
return JSON.stringify(p.map(function(x){return x.id;}));
})()
EVALEOF
# click the iMessage tile, then fill the form by ref
agent-browser --cdp 9222 eval "(()=>{var b=[...document.querySelectorAll('aside button')].find(x=>/imessage/i.test(x.textContent));b&&b.click();})()"
agent-browser --cdp 9222 wait 1500
agent-browser --cdp 9222 snapshot -i | grep -iE "127.0.0.1:1234|Application ID|Webhook Secret|Test BlueBubbles|Save Bridge"
Field refs (from the snapshot): Application ID, Webhook Secret, BlueBubbles
Server URL (placeholder="http://127.0.0.1:1234"), and a nested textbox right
under the URL one is the BlueBubbles Password. Fill with fill (real input
events — eval-setting React inputs won't fire onChange), click Test
BlueBubbles, then Save Bridge. Read the antd toast immediately (it
auto-dismisses):
agent-browser --cdp 9222 eval \
"JSON.stringify([...new Set([...document.querySelectorAll('.ant-message-custom-content')].map(n=>n.textContent.trim()))])"
# Test → "BlueBubbles connection passed"
# Save → "iMessage Desktop bridge saved"
Verify the end state via BlueBubbles + IPC:
curl -sS "http://127.0.0.1:1234/api/v1/webhook?password=<PW>" # webhook for the appId present
agent-browser --cdp 9222 eval "(async()=>JSON.stringify(await window.electronAPI.invoke('imessageBridge.getStatus',{})))()"
# running:true, serverUrl: http://127.0.0.1:33270, configs[].blueBubblesPasswordSet:true
Cleanup: removeConfig + stop via IPC, then DELETE /api/v1/webhook/<id> on
BlueBubbles.
Verifies the leg the bridge uses to reply: BlueBubblesApiClient.sendText
→ POST /api/v1/message/text. Run the helper against your own number:
./.agents/skills/local-testing/bot/imessage/send-imessage-test.sh '<bb_password>' '+<E164>' # e.g. +15551234567
Gotcha that bites everyone: with method=apple-script and a new
conversation, the HTTP POST often times out even though the message is
sent. Never judge success by the HTTP response. Instead poll
POST /api/v1/message/query and read the matching isFromMe:true row's
error field:
error: 0 (or null) → sent OKerror → real send failureThe script does exactly this: fires the send, ignores the timeout, then matches
its marker text in the message store and asserts error == 0.
Two more notes:
iMessage;-;+<countrycode><number>) or an Apple ID
email. Looking the chat up by guid afterwards may 404 if BB filed the message
under a differently-formatted guid — that's a lookup quirk, not a send failure.fromMe:true) and an incoming copy (fromMe:false).Full inbound chain: a message arrives → BlueBubbles fires its new-message
webhook → local bridge (:33270) → forwardWebhook POSTs to
<remote>/api/agent/webhooks/imessage/<appId>?secret=… → cloud agent → reply
flows back via Device Gateway → BB sendText.
Prerequisites:
applicationId exists and is connected
(Save Configuration + the device gateway connected — a disconnected gateway
yields DEVICE_NOT_FOUND on connect and blocks the reply leg).imessage Labs toggle is on (otherwise the channel is gated to "Coming
Soon"), and webhookSecret matches on both ends (auto-generated on save).Two ways to drive it:
sendText to the hosted
number; the loopback incoming copy (isFromMe:false) triggers the bot.
Watch the reply land in message/query as a fromMe:true row.Loop guard — why a self-send doesn't spin forever: the Chat SDK adapter
drops any isFromMe message before dispatch
(packages/chat-adapter-imessage/src/adapter.ts: if (message.isFromMe) return).
The bot's own reply (isFromMe:true) is never re-processed, so in the normal
case (someone else → bot → reply to them) there is no loop. The self-send case
is a test-only edge: the bot's reply also round-trips to your number, and
only the adapter's isFromMe check stops a second pass. Keep the prompt
conversational (so the bot doesn't keep finding something to answer), and
turn the imessage lab off / remove the config when done — never leave a
self-send bot running unattended.
Watch the chain live:
tail -f /tmp/electron-dev.log | grep -iE "imessage|bridge|forward|Message API"
# the agent reply shows up as a fromMe:true row with the bot's text:
curl -sS -X POST "http://127.0.0.1:1234/api/v1/message/query?password=<PW>" \
-H 'Content-Type: application/json' -d '{"limit":5,"sort":"DESC"}'
startTyping will log a Private-API error unless BlueBubbles has the Private
API helper set up (needs a jailbroken / SIP-disabled Mac) — it's logged and
ignored; text replies still work.
GET /api/v1/webhook?url=<unregistered> returns HTTP 500
(Cannot read properties of null (reading 'events')). The bridge must list
all webhooks and match client-side, never pass the ?url= filter. If you
see upsertConfig fail with "An unhandled error has occurred!" originating in
listWebhooks, this regressed.upsertConfig writes the
config + starts the HTTP server before registering the webhook, so a webhook
failure still reports running:true with the config persisted but no
BlueBubbles webhook. Always assert the BlueBubbles webhook list, not just IPC
status.lobe-backend:// to
the remote server. iMessage only appears in listPlatforms once the server
registration is deployed there, regardless of local branch.imessageBridgeSrv.ts /
@lobechat/chat-adapter-imessage needs electron-dev.sh restart — main isn't
hot-replaced. On restart, enabled configs auto-register their webhook again.