Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Messengers

The messaging services an agent answers on, each with what it takes to connect and how the bridge knows who writes. The commands, approvals, files and replies of Channels hold on all of them; what they do not do yet is listed there.

Outputs on this page are from real runs on a local devnet, long identifiers and some JSON shortened with …. The models are scripted, and the messengers run against stand-ins for their APIs, because the environment these pages were made in reaches none of them. The runs of subagents and hiring predate specification v1.13 (run again on v1.13, they printed the same lines with other identifiers); a profile’s own tokens, Slack and the agent gateway are from a run on v1.15.

Telegram

sion agent telegram answers a profile’s chats by long polling, with the bot token in TELEGRAM_BOT_TOKEN. It answers only the chats in --allow, and with --approvers, only the Telegram users named may approve or decline. Here TELEGRAM_API_BASE points it at the stand-in, which returned two messages, from chat 42 and from chat 99:

$ TELEGRAM_BOT_TOKEN=… TELEGRAM_API_BASE=http://127.0.0.1:8792 sion agent telegram lead --allow 42 --once
chat 42: How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"85",…}
  agent: 85 USD left in the budget.
  session 9fdce72752d0f32a: done
to chat 42: 85 USD left in the budget.
chat 99: not allowed, ignored

The stand-in received one message, for chat 42:

sendMessage {"chat_id": 42, "text": "85 USD left in the budget."}

Slack, Discord, Matrix and Signal

sion agent bridge <profile> --via <messenger> answers a messenger’s chats with a profile, through the messenger’s own API, with credentials from the environment:

--viaReceives byCredentials--allow names
slackSocket Mode, given an app-level token; otherwise polling conversations.historySLACK_BOT_TOKEN, and SLACK_APP_TOKEN for Socket Modechannel ids
discordits gatewayDISCORD_BOT_TOKENchannel ids
matrixlong polling /syncMATRIX_HOMESERVER, MATRIX_ACCESS_TOKENroom ids
signalpolling the receive endpoint of signal-cli-rest-apiSIGNAL_API_BASE, SIGNAL_NUMBERphone numbers
telegramlong polling getUpdatesTELEGRAM_BOT_TOKENchat ids
mattermostits WebSocketMATTERMOST_URL, MATTERMOST_TOKENchannel ids
irca connection of its own (ircs://, or irc://), with a SASL login if IRC_PASSWORD is setIRC_SERVER, IRC_NICK, IRC_PASSWORD#channels (joined), and nicks
twitchTwitch’s chat, over IRCTWITCH_NICK, TWITCH_TOKEN#channels (joined)
whatsapp, line, smssigned webhooks to --listen (below)numbers, user and group ids

sion agent telegram is the same bridge. It does not answer bots: Telegram does not show a bot other bots’ messages, Slack and Discord mark them, and on Matrix the bridge skips its own. On Signal it answers direct messages only. It keeps its place, so a restarted bridge goes on where it stopped, and the first time it polls Slack or Matrix, it starts from the latest message rather than answering the history; Discord’s gateway and Slack’s Socket Mode pass on only what is said while the bridge listens. With --approvers, only the people named, as the messenger names them, may approve or decline. With --require-mention, the group chats it answers are answered only where a message mentions the bot or answers one of its messages; direct messages are always answered. A chat it does not answer is said to be ignored once. On IRC and Twitch, a reply goes out as lines of about 400 bytes, eight at most, no faster than the networks allow (Twitch’s, one every 1.6 seconds); someone who writes lead: … or names the bot mentions it.

Here SLACK_API_BASE points the bridge at a stand-in for Slack’s Web API. Its first poll found one old message, which the bridge took as where to start; the second found two new messages and a bot’s:

$ SLACK_BOT_TOKEN=… SLACK_API_BASE=http://127.0.0.1:8796 sion agent bridge lead --via slack --allow C042 --approvers U07 --once
$ SLACK_BOT_TOKEN=… SLACK_API_BASE=http://127.0.0.1:8796 sion agent bridge lead --via slack --allow C042 --approvers U07 --once
channel C042 (U07): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 3c0aa9d0beb2afb0: done
to channel C042: 100 USD left in the budget.
channel C042 (U19): /approve
to channel C042: Only U07 may approve or decline here.

The stand-in received:

chat.postMessage {"channel": "C042", "text": "100 USD left in the budget."}
chat.postMessage {"channel": "C042", "text": "Only U07 may approve or decline here."}

Discord’s gateway and Slack’s Socket Mode

Discord passes a bot what is said over its gateway, a WebSocket the bridge keeps open: it sends heartbeats, resumes after a drop, and connects again after a pause that doubles up to a minute. The bot needs Discord’s message-content intent, turned on in its developer portal; if Discord refuses the token or the intent, the bridge stops and says why. Slack does the same through Socket Mode once the app has an app-level token (SLACK_APP_TOKEN, xapp-…, with connections:write): the bridge acknowledges each event as it comes, and a message Slack sends twice, as a message and as a mention, is answered once. Both pass on direct messages as well as channels’, so both pair. When the bridge stops, it closes the connection, and Discord shows the bot offline.

Here DISCORD_API_BASE and SLACK_API_BASE point the bridge at stand-ins, whose gateway and Socket Mode are WebSockets on this machine. Discord’s sent four messages: one in channel 900 that mentions the bot, another there that does not, one in channel 901, and a direct message (channel 1300) from someone the bridge does not know. Slack’s sent one message in C042, as a message and as a mention, and a direct message (D088):

$ DISCORD_BOT_TOKEN=… DISCORD_API_BASE=http://127.0.0.1:8796/discord sion agent bridge lead --via discord --allow 900 --pairing --require-mention --once
channel 900 (2201): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 6a0b4d998c8b3a40: done
to channel 900: 100 USD left in the budget.
channel 901: not allowed, ignored
channel 1300: not known yet; sent a pairing code (sion agent pairing list)
$ SLACK_BOT_TOKEN=… SLACK_APP_TOKEN=… SLACK_API_BASE=http://127.0.0.1:8796/slack sion agent bridge lead --via slack --allow C042 --pairing --once
channel C042 (U07): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 573b1fd79d14ee81: done
to channel C042: 100 USD left in the budget.
channel D088: not known yet; sent a pairing code (sion agent pairing list)

The stand-ins received:

gateway: identify op=2 intents=37377 token=…
POST /channels/900/messages {"content":"100 USD left in the budget."}
POST /channels/1300/messages {"content":"I do not know you yet. To talk with me, ask the person who runs me to let you in with: sion agent pairing approve VDWH-KNS9"}
gateway: closed by the bridge
socket mode: {"envelope_id": "e1"}
socket mode: {"envelope_id": "e2"}
socket mode: {"envelope_id": "e3"}
chat.postMessage {"channel":"C042","text":"100 USD left in the budget."}
chat.postMessage {"channel":"D088","text":"I do not know you yet. To talk with me, ask the person who runs me to let you in with: sion agent pairing approve QNB5-KNPN"}
socket mode: closed by the bridge

The intents, 37377, are servers, their messages, direct messages and message content.

IRC and Twitch

--via irc keeps a connection of its own to IRC_SERVER (ircs:// for TLS), logs in with SASL when IRC_PASSWORD is set, joins the #channels in --allow, and answers the server’s pings; a nick in --allow is a private conversation. --via twitch is the same on Twitch’s chat, with the bot’s OAuth token. Here IRC_SERVER points the bridge at a stand-in, where, once the bot had joined, ann wrote to it and bob to the channel:

$ IRC_SERVER=irc://127.0.0.1:8793 IRC_NICK=lead sion agent bridge lead --via irc --allow "#ops" --require-mention --once
channel #ops (ann): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session ccc37f29472e2dd6: done
to channel #ops: 100 USD left in the budget.

The server received:

NICK lead
USER lead 0 * :sion
JOIN #ops
PONG :irc.example.org
PRIVMSG #ops :100 USD left in the budget.
QUIT :stopped

lead: How much budget is left? mentions the bot; lunch at noon? does not, and with --require-mention it is not answered.

WhatsApp, LINE and SMS

WhatsApp (Meta’s Cloud API), LINE (its Messaging API) and SMS (through Twilio) send what is said to the bridge as webhooks. The bridge listens on an address of its own (--listen, 127.0.0.1:8797 by default), which you expose to the service behind TLS, through a reverse proxy or a tunnel. Each webhook is checked against the service’s signature with the secret you give, and one that is unsigned or signed with another secret is refused before anything reaches the profile. A service sends again what it thinks was not received; each message is answered once.

--viaCredentialsThe signature checkedAnswers through
whatsappWHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKENX-Hub-Signature-256, with the app secret; Meta’s check of the address is answered given the verify tokenthe Cloud API’s /messages
lineLINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRETX-Line-Signature, with the channel secreta reply, while the message’s reply token lasts (50 seconds here), and a push after
smsTWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_NUMBER, TWILIO_WEBHOOK_URLX-Twilio-Signature, with the auth token, over the public address Twilio is given (TWILIO_WEBHOOK_URL, exactly as Twilio has it)Twilio’s Messages API

Voice notes are heard on all three (WhatsApp’s audio messages, LINE’s, and audio sent by MMS) and spoken as an audio message on WhatsApp; LINE and MMS play audio only from a public address of your own, so there the reply is text.

Here each bridge ran with --once while a stand-in sent it what the service would, and another stood in for the service’s API (WHATSAPP_API_BASE, LINE_API_BASE and TWILIO_API_BASE pointed at it):

$ WHATSAPP_TOKEN=… WHATSAPP_PHONE_NUMBER_ID=106 WHATSAPP_APP_SECRET=… WHATSAPP_VERIFY_TOKEN=… WHATSAPP_API_BASE=http://127.0.0.1:8798/meta sion agent bridge lead --via whatsapp --allow 15550001111 --listen 127.0.0.1:8797 --once
whatsapp: webhooks on http://127.0.0.1:8797/
number 15550001111: How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 010e73028a82599f: done
to number 15550001111: 100 USD left in the budget.

What the stand-in for WhatsApp sent the bridge, and the answers:

GET ?hub.mode=subscribe&hub.verify_token=…&hub.challenge=4242: HTTP 200 4242
POST (unsigned): HTTP 401 not signed with the app secret
POST (X-Hub-Signature-256: sha256=…): HTTP 200 ok

LINE and SMS, the same way:

$ LINE_CHANNEL_ACCESS_TOKEN=… LINE_CHANNEL_SECRET=… LINE_API_BASE=http://127.0.0.1:8798/line sion agent bridge lead --via line --allow U4af4980629 --listen 127.0.0.1:8796 --once
line: webhooks on http://127.0.0.1:8796/
chat U4af4980629: How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 810e6fb01af77fad: done
to chat U4af4980629: 100 USD left in the budget.
$ TWILIO_ACCOUNT_SID=AC0001 TWILIO_AUTH_TOKEN=… TWILIO_NUMBER=+15559990000 TWILIO_WEBHOOK_URL=https://bot.example.org/sms TWILIO_API_BASE=http://127.0.0.1:8798/twilio sion agent bridge lead --via sms --allow +15550001111 --listen 127.0.0.1:8795 --once
sms: webhooks on http://127.0.0.1:8795/
number +15550001111: How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 01959b3c06d8963d: done
to number +15550001111: 100 USD left in the budget.
POST (X-Line-Signature: another secret's): HTTP 401 not signed with the channel secret
POST (X-Line-Signature: …): HTTP 200 ok
POST (unsigned): HTTP 401 not signed with the auth token
POST (X-Twilio-Signature: …): HTTP 200 <?xml version="1.0" encoding="UTF-8"?><Response></Response>

The stand-in for the three APIs received:

POST /meta/106/messages {"messaging_product":"whatsapp","text":{"body":"100 USD left in the budget."},"to":"15550001111","type":"text"}
POST /line/v2/bot/message/reply {"messages":[{"text":"100 USD left in the budget.","type":"text"}],"replyToken":"r-0042"}
POST /twilio/2010-04-01/Accounts/AC0001/Messages.json To=%2B15550001111&From=%2B15559990000&Body=100%20USD%20left%20in%20the%20budget.

Microsoft Teams and Google Chat

Teams and Google Chat send what is said to the bridge as webhooks too, to its --listen address, which you expose to them behind TLS. Neither signs a webhook with a secret you share: each sends a token (a JWT, signed with RS256), which the bridge checks against the keys the service publishes, fetched over TLS and kept a day, before anything reaches the profile.

--viaCredentialsThe token takenAnswers through
teamsTEAMS_APP_ID, TEAMS_APP_PASSWORD, and TEAMS_TENANT_ID for a single-tenant botthe Bot Connector’s (https://api.botframework.com), for the app ID, by a key endorsed for the channel, naming the activity’s service URLthe Connector API at that service URL, with a token Microsoft Entra ID gives the app, as a reply to the message
google-chatGOOGLE_CHAT_AUDIENCE (the project number, or the endpoint’s URL), and GOOGLE_CHAT_CREDENTIALS (a service account’s key file) to answer at any timeChat’s own (chat@system.gserviceaccount.com) for the project number, or Google’s ID token of GOOGLE_CHAT_SENDER for the URLthe webhook’s own answer, while Chat waits (thirty seconds); with a service account’s key, the Chat API, in the message’s thread

In a Teams channel the bot is written to by mentioning it, and the mention is cut from what the agent reads; a personal chat is a direct one, for pairing. Google Chat’s events come in either of the shapes Chat sends: an interaction event, or a Workspace add-on’s. Without a service account, an answer the agent takes longer over than Chat waits is not delivered, and the bridge says so; with one, it goes whenever it comes, and voice notes are fetched to be heard.

Here each bridge ran with --once while a stand-in sent it what the service would, its tokens signed with a key the stand-in for the service published (TEAMS_OPENID_URL, TEAMS_LOGIN_URL and GOOGLE_CHAT_KEYS_URL pointed the bridge at it):

$ TEAMS_APP_ID=app-1 TEAMS_APP_PASSWORD=… TEAMS_TENANT_ID=tenant-1 TEAMS_OPENID_URL=http://127.0.0.1:8790/openid TEAMS_LOGIN_URL=http://127.0.0.1:8790 sion agent bridge lead --via teams --allow a:1chat --listen 127.0.0.1:8789 --once
teams: webhooks on http://127.0.0.1:8789/
conversation a:1chat (aad-alice): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session c92b696768ee4fb1: done
to conversation a:1chat: 100 USD left in the budget.
$ GOOGLE_CHAT_AUDIENCE=1234567890 GOOGLE_CHAT_KEYS_URL=http://127.0.0.1:8790/jwk/chat sion agent bridge lead --via google-chat --allow spaces/DM1 --listen 127.0.0.1:8788 --once
google-chat: webhooks on http://127.0.0.1:8788/
space spaces/DM1 (users/111): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 4d0f4925547a856c: done
to space spaces/DM1: 100 USD left in the budget.

What the stand-ins for Teams and Chat sent the bridge, and the answers:

POST (no token): HTTP 401 no token
POST (a token for another bot): HTTP 401 a token for another audience ("app-2")
POST (the Bot Connector's token): HTTP 200 ok
POST (a token for another project): HTTP 401 a token for another audience ("999")
POST (Chat's token): HTTP 200 {"text":"100 USD left in the budget."}

What the stand-ins for Microsoft and Google were asked, the secrets named and not shown:

GET /openid
GET /keys
POST /tenant-1/oauth2/v2.0/token (client_id, client_secret, grant_type, scope)
POST /amer/v3/conversations/a%3A1chat/activities/act-1 (Authorization: Bearer …) {"replyToId":"act-1","text":"100 USD left in the budget.","textFormat":"markdown","type":"message"}
GET /jwk/chat

DingTalk, Feishu and Nextcloud Talk

--viaCredentialsReceives byAnswers through
dingtalkDINGTALK_CLIENT_ID, DINGTALK_CLIENT_SECRET (an internal app’s robot)Stream mode: a WebSocket the bridge opens to DingTalk, every frame acknowledged, so there is no address to exposethe message’s session webhook, or after it expires the robot’s API with the app’s token
feishu, larkFEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_VERIFICATION_TOKEN; FEISHU_ENCRYPT_KEY if the subscription has an Encrypt Keywebhooks to --listen carrying the subscription’s verification token, and with an Encrypt Key encrypted and signeda reply to the message through the Open Platform’s API, with the tenant token
nextcloud-talkNEXTCLOUD_URL, NEXTCLOUD_TALK_SECRET (a bot installed with occ talk:bot:install)webhooks to --listen, signed with the bot’s secret by the server namedTalk’s bot API, signed the same way, as a reply to the message

DingTalk transcribes voice messages itself, and the agent is given what it heard, marked as such. Feishu’s voice messages are fetched to be heard (--hear), and its pictures and files to be shown or kept. With an Encrypt Key, Feishu encrypts each event (AES-256) and signs it: an event not signed with the key is refused. Talk’s webhooks do not say whether a conversation is one person’s, so there is no pairing on Talk; name the conversations (--allow, by their tokens).

Here each bridge ran with --once against stand-ins for the three (DINGTALK_API_BASE, FEISHU_API_BASE and NEXTCLOUD_URL pointed at them):

$ DINGTALK_CLIENT_ID=ding-app DINGTALK_CLIENT_SECRET=… DINGTALK_API_BASE=http://127.0.0.1:8786 sion agent bridge lead --via dingtalk --allow cid-dm --once
conversation cid-dm (staff7): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 5f964ff5927c0f96: done
to conversation cid-dm: 100 USD left in the budget.
$ FEISHU_APP_ID=cli_a1 FEISHU_APP_SECRET=… FEISHU_VERIFICATION_TOKEN=… FEISHU_API_BASE=http://127.0.0.1:8786 sion agent bridge lead --via feishu --allow oc_dm --listen 127.0.0.1:8785 --once
feishu: webhooks on http://127.0.0.1:8785/
chat oc_dm (ou_alice): How much budget is left?
  (the reply is not shown as it is written: Feishu made no card)
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 6eccad7c90a2c784: done
to chat oc_dm: 100 USD left in the budget.
$ NEXTCLOUD_URL=http://127.0.0.1:8786 NEXTCLOUD_TALK_SECRET=… sion agent bridge lead --via nextcloud-talk --allow n3xtc10ud --listen 127.0.0.1:8784 --once
nextcloud-talk: webhooks on http://127.0.0.1:8784/
conversation n3xtc10ud (users/alice): How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 6e03b59d2b58e874: done
to conversation n3xtc10ud: 100 USD left in the budget.

What the stand-in for DingTalk was told, a ping and a message acknowledged over its WebSocket:

POST /v1.0/gateway/connections/open (clientId, clientSecret, localIp, subscriptions, ua)
WebSocket /connect?ticket=ticket-1
acknowledged: {"code":200,"data":"{\"opaque\":\"x\"}","headers":{"contentType":"application/json","messageId":"p1"},"message":"OK"}
acknowledged: {"code":200,"data":"{\"response\":\"OK\"}","headers":{"contentType":"application/json","messageId":"c1"},"message":"OK"}
POST /robot/sendBySession?session=s1 {"msgtype":"text","text":{"content":"100 USD left in the budget."}}
WebSocket closed by the bridge

What the stand-ins for Feishu and Talk sent the bridge, and the answers:

POST (the address's check): HTTP 200 {"challenge":"c-42"}
POST (another app's token): HTTP 401 not the app's verification token
POST (encrypted): HTTP 400 an encrypted event: set FEISHU_ENCRYPT_KEY to the subscription's Encrypt Key
POST (the app's token): HTTP 200 {}
POST (unsigned): HTTP 401 not signed with the bot's secret
POST (signed, from another server): HTTP 401 from another server
POST (signed with the bot's secret): HTTP 200 ok

And what their APIs were asked, the secrets named and not shown:

POST /open-apis/auth/v3/tenant_access_token/internal (app_id, app_secret)
POST /open-apis/im/v1/messages/om_1/reactions (Authorization: Bearer …) {"reaction_type":{"emoji_type":"Typing"}}
POST /open-apis/cardkit/v1/cards (Authorization: Bearer …) {"data":"{\"body\":{\"elements\":[{\"content\":\"… ledger_balance\",\"element_id\":\"reply\",\"tag\":\"markdown\"}]},\"config\":{\"streaming_config\":{\"print_frequency_ms\":{\"default\":30},\"print_step\":{\"default\":2},\"print_strategy\":\"fast\"},\"streaming_mode\":true},\"schema\":\"2.0\"}","type":"card_json"}
POST /open-apis/im/v1/messages/om_1/reply (Authorization: Bearer …) {"content":"{\"text\":\"100 USD left in the budget.\"}","msg_type":"text"}
POST /ocs/v2.php/apps/spreed/api/v1/bot/n3xtc10ud/reaction/1567 (signed: random, signature) {"reaction":"⏳"}
POST /ocs/v2.php/apps/spreed/api/v1/bot/n3xtc10ud/message (signed: random, signature) {"message":"100 USD left in the budget.","referenceId":"f91ac0ee1d62106e2839bf19c83d9238c61a02b5211837fd629ab915d4875f66","replyTo":1567}

Email

--via email reads the agent’s own mailbox over IMAP and answers over SMTP. Each person who writes is a chat, named by their address (--allow alice@example.org), and the reply goes to them alone, in the thread of what they wrote. What the agent reads is what they wrote themselves: the message they quote and their signature are left out, and the subject comes first where a conversation starts.

VariableWhat it is
EMAIL_ADDRESSthe agent’s address
EMAIL_IMAP_URLimaps://imap.example.org (TLS, port 993), or imap://… (STARTTLS, port 143)
EMAIL_SMTP_URLsmtps://smtp.example.org (TLS, port 465), or smtp://… (STARTTLS, port 587)
EMAIL_PASSWORDthe password: an app password, where the provider has them
EMAIL_TOKEN_COMMANDinstead of a password, a command that prints an OAuth access token, for providers that want one (XOAUTH2)
EMAIL_USERNAME, EMAIL_MAILBOX, EMAIL_NAMEthe login if it is not the address, the mailbox read (INBOX), and the name replies are sent under
EMAIL_AUTHSERV_IDthe name your provider signs its Authentication-Results with (mx.google.com; a domain stands for its servers)
EMAIL_TRUST_FROM1 to take From as it is, on a server that checks nothing and where no stranger can write

Anyone can put any address in From. The bridge believes it only as far as your provider vouches for it: it reads the Authentication-Results the provider adds to each message it receives (RFC 8601), and answers only where DMARC passed for the sender’s domain, or DKIM or SPF passed for a domain aligned with it. It reads the topmost results, which the provider writes above everything a sender wrote, or with EMAIL_AUTHSERV_ID, the topmost the provider signed with that name. A message the provider did not check is not answered; EMAIL_TRUST_FROM=1 takes From as it is. Mail written by machines is not answered either: out-of-office replies, bounces and delivery reports, mailing lists and bulk mail. The replies say Auto-Submitted: auto-replied (RFC 3834), so that two machines do not answer each other.

Nothing is sent in the clear: an imap:// or smtp:// server must offer STARTTLS, unless it is on a loopback address (a bridge on the machine itself, as Proton Mail’s is), and anything a server sends between agreeing to TLS and the handshake is refused. The bridge reads with BODY.PEEK, so no message is marked read or changed. It keeps its place by UID, starts after the newest message the first time, and waits for mail with IDLE where the server has it. Attachments are files for the agent, ten of a message at most (Pictures and files); audio ones are voice notes for --hear, and an answer spoken with --speak goes back as an audio attachment.

Here a stand-in for a provider offered STARTTLS on both servers, with a certificate from a CA made for the recording (SSL_CERT_FILE names it). The first run answered nothing, starting after the message already there. Then three came: Alice’s question; one saying it was from Alice that her domain’s DMARC failed, the sender having written a passing result below the provider’s; and her out-of-office reply.

$ EMAIL_ADDRESS=lead@example.net EMAIL_PASSWORD=… EMAIL_IMAP_URL=imap://localhost:8791 EMAIL_SMTP_URL=smtp://localhost:8792 EMAIL_AUTHSERV_ID=mx.example.net SSL_CERT_FILE=ca.pem sion agent bridge lead --via email --allow alice@example.org --once
$ sion agent bridge lead --via email --allow alice@example.org --once
address alice@example.org: DMARC fail for example.org, ignored
address alice@example.org: an automatic message (Auto-Submitted: auto-replied), ignored
address alice@example.org: Subject: Budget

How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session 98398c65086bd867: done
to address alice@example.org: 100 USD left in the budget.

What the servers were told on the second run, the logins shown as …:

imap: s1 STARTTLS
imap: (TLS from here)
imap: s2 CAPABILITY
imap: s3 AUTHENTICATE PLAIN …
imap: s4 CAPABILITY
imap: s5 SELECT "INBOX"
imap: s6 UID FETCH 2:* (UID RFC822.SIZE)
imap: s7 UID FETCH 2 (BODY.PEEK[])
imap: s8 UID FETCH 3 (BODY.PEEK[])
imap: s9 UID FETCH 4 (BODY.PEEK[])
smtp: EHLO example.net
smtp: STARTTLS
smtp: (TLS from here)
smtp: EHLO example.net
smtp: AUTH PLAIN …
smtp: MAIL FROM:<lead@example.net>
smtp: RCPT TO:<alice@example.org>
smtp: DATA
smtp: (the message)
  From: <lead@example.net>
  To: <alice@example.org>
  Subject: Re: Budget
  Date: Fri, 30 Oct 2026 22:10:13 +0000
  Message-ID: <936c7a877f9df40f0d4c6f0d442381a3@example.net>
  In-Reply-To: <c1f3@mail.example.org>
  References: <c1f3@mail.example.org>
  Auto-Submitted: auto-replied
  MIME-Version: 1.0
  Content-Type: text/plain; charset=utf-8
  Content-Transfer-Encoding: quoted-printable

  100 USD left in the budget.
smtp: QUIT
imap: s10 LOGOUT

iMessage

--via imessage answers Messages on a Mac through imsg, the command line OpenClaw drives too: the bridge runs imsg rpc and speaks its JSON-RPC, on the Mac itself or, with IMSG_COMMAND="ssh mac imsg rpc", from another machine. imsg reads Messages’ database (it needs Full Disk Access) and sends through Messages (Automation): there is no server to expose and no password to keep.

The bridge subscribes to the messages after its place, a message’s row id, so what came while it was stopped is answered; the first time, it starts after the latest message. A chat is named by its identifier: a handle for a direct chat (--allow +15551234567, or an Apple ID’s address), chat… for a group. What is sent from the Mac itself is the agent’s own, and not answered. Voice notes (Core Audio files) are heard as imsg converts them, and other attachments are files for the agent (as imsg converts them too: HEIC pictures to JPEG), where the bridge runs on the Mac; spoken answers go as audio messages, with their text. IMSG_SERVICE sends replies as imessage, sms or auto.

Here imsg was a stand-in speaking its JSON-RPC (imsg runs only on macOS). After the first run, which answered nothing, came a question, the Mac’s own reply to an earlier one, and a group’s message:

$ IMSG_COMMAND="python3 -I fake-imsg.py messages.json imsg-asked.jsonl" sion agent bridge lead --via imessage --allow +15550001111 --once
$ sion agent bridge lead --via imessage --allow +15550001111 --once
chat +15550001111: How much budget is left?
    → ledger_balance {}
    ← {"budget_remaining_usd":"100",…}
  agent: 100 USD left in the budget.
  session e7382195f8970032: done
to chat +15550001111: 100 USD left in the budget.
chat chat777: not allowed, ignored

What imsg was asked over the two runs:

{"method": "chats.list", "params": {"limit": 1}}
{"method": "messages.history", "params": {"chat_id": 1, "limit": 1}}
{"method": "watch.subscribe", "params": {"attachments": true, "since_rowid": 10}}
{"method": "watch.subscribe", "params": {"attachments": true, "since_rowid": 10}}
{"method": "send", "params": {"chat_identifier": "+15550001111", "text": "100 USD left in the budget."}}

WeCom, QQ and Zalo

--via wecom answers a WeCom (企业微信) self-built app. WeCom posts each message to --listen encrypted and signed, as its WXBizMsgCrypt has it: AES-256-CBC under the callback’s EncodingAESKey, and a SHA-1 signature over the callback’s token, the time, a nonce and the ciphertext. A callback whose signature is not the token’s, or whose message does not decrypt to one for the company, is refused before anything reaches the profile; the address’s check (echostr) is answered decrypted, as WeCom asks. Replies go through the app’s API (message/send) with an access token from the corp id and the app’s secret, renewed before it lapses; pictures, voice messages and files come through media/get, and what the agent shares goes up through media/upload.

VariableWhat
WECOM_CORP_IDthe company’s id, which every callback names
WECOM_AGENT_ID, WECOM_SECRETthe app’s id and secret
WECOM_TOKEN, WECOM_AES_KEYthe callback’s token and EncodingAESKey

--via qq answers a bot of QQ’s bot platform (QQ 机器人) in webhook mode: QQ signs each event with Ed25519 under a key both sides derive from the bot’s secret, and the bridge answers the address’s check (op 13) by signing its token back, signing nothing that could pass for an event. A chat is named by its kind: user:<openid> for a person’s direct messages, group:<openid> for a group that mentions the bot, channel:<id> for a channel of a QQ guild. Replies are QQ’s passive ones, each part numbered (msg_seq), while QQ takes them for the message (a few minutes); later a message is sent unprompted, which QQ allows only a few times a month (QQ_APP_ID, QQ_APP_SECRET).

--via zalo answers a bot of the Zalo Bot Platform, whose API has Telegram’s shape: getUpdates long polled, sendMessage, sendChatAction for typing (ZALO_BOT_TOKEN). A picture or a voice message comes as an address on Zalo’s servers, fetched to be shown or heard; the bot sends no files, as Zalo takes a bot’s pictures and voice only from a public address.

WeChat

--via weixin answers a WeChat (微信) account through Tencent’s iLink bot protocol, the one its own ClawBot plugin speaks. sion agent weixin login draws a QR code in the terminal: scan it with WeChat on the phone and confirm, and the bot’s token is kept encrypted beside the secrets (sion agent weixin logout forgets it; WEIXIN_BOT_TOKEN gives one instead). The bridge long polls getupdates, and each reply carries the context token of the message it answers, as iLink asks. Text is taken, and a voice message as WeChat transcribed it; pictures and files, which iLink keeps encrypted on its CDN, are named to the agent, not fetched. The protocol is published only as Tencent’s plugin’s code and others’ descriptions of it, which is what the bridge speaks.

X, Synology Chat and SimpleX

--via x answers an X account’s direct messages, polled through the X API v2 (/2/dm_events) once a minute, as X’s smaller tiers allow (X_POLL_SECS says otherwise), and answered in the conversation with whoever wrote. X_ACCESS_TOKEN is a user’s OAuth 2.0 token with dm.read, dm.write, users.read and tweet.read; with X_REFRESH_TOKEN and X_CLIENT_ID (and X_CLIENT_SECRET for a confidential app) it is renewed when X refuses it. The first poll takes the bridge’s place: what was written before it started is not answered.

Mentions on X

sion agent x-mentions <profile> answers the posts that mention the account, in reply to each, once a minute (--poll). X lets an account’s automated replies go only to posts that mention or quote it, so a mention is answered once, in its own thread, and no one else is. The token needs tweet.read, tweet.write and users.read (dm.read and dm.write too if the same token serves --via x); the tokens a renewal gives are kept sealed on this machine, since X ends the refresh token each renewal uses.

An answer rests on what was read for it, not on what the model remembers. Before the session starts, the runner gathers the sources: the post the mention replies to or quotes, and, with a search service set up (web_search’s Brave, Tavily or SearXNG), its results for the mention’s own words, the first two pages of them read (--read). The session is given them numbered, as data, and asked to answer from them in at most 600 characters, citing each claim, like [1]. Its answer is posted only if every number it cites is a source it was given, it names no address it was not given (anything X would make a link of: with a scheme in any case, from www., or a bare domain), and it cites at least one source (--sources optional lets an answer with none through). An @ before a name is taken off, so that a reply mentions no one, and it addresses only the mention’s author, not the others the mention named. The check is of what an answer rests on, not of what it says: a model can cite a source for a claim the source does not make. The reply ends with the sources it cites and their addresses:

It closes at noon today [1], for roadworks [2].

Sources: [1] https://x.com/cy/status/90 [2] https://city.example/notices

What a stranger writes is public input, so the session it starts is tainted from the start, and it answers with the profile’s models and instructions only: no memory, no skills (not even their names), no workspace, and no tools at all, since what it answers from is gathered before; nothing a mention could draw out of it into a public reply, or steer into what the profile does later. A profile marked confidential is refused.

A post is at most 280 weighted characters (an address counts 23), and X bills a post with an address far more than one without, so a longer answer is split at its sentences into up to --max-posts posts (3), chained, with all the addresses in the last. --per-day (50) and --per-person (5) bound how many mentions are taken on each UTC day, whether a reply comes of them or not, since each costs a search and a session. Someone who writes stop is not answered again until they write start; the account’s own posts, retweets and protected accounts’ posts are passed over. --from @ana,@bo answers only those people, and --dry-run prints the replies without posting them, and keeps nothing. The first run answers nothing: it starts from the newest mention. A poll reads up to 500 new mentions, a page of 100 at a time. What it keeps (its place, the mentions taken on, who said stop, the day’s counts, the replies posted) is in agents/x-mentions/<profile>.json, saved as each mention is taken on, before its reply is posted, so that none is answered twice.

A profile that may read the web also gets x_search, recent posts (the last seven days) searched through the same token, X’s operators (from:, lang:, -is:retweet) included, when X_ACCESS_TOKEN is set. X bills each post read; what posts say comes back as from outside.

--via synology-chat answers a Synology Chat chatbot: Synology Chat posts what people write to the bot to --listen (the bot’s outgoing URL), each request carrying the bot’s token (SYNOLOGY_CHAT_TOKEN), and the reply goes through the bot’s incoming URL (SYNOLOGY_CHAT_INCOMING_URL, as Synology Chat shows it), to the user who wrote, named by the id Synology Chat gives.

--via simplex answers a SimpleX Chat profile through the WebSocket API of SimpleX’s terminal client, run on the same machine with the bot’s profile (simplex-chat -p 5225; SIMPLEX_URL names another address). The client’s API asks no password, so it listens on localhost only, and the bridge must be its only program. A chat is @<contact id> or #<group id>, as the client numbers them; in a group, the bot answers what mentions it. The profile’s address, and accepting whoever asks to connect, are set up in the client (/address, /auto_accept on); the bridge answers only the chats it is let into, or pairs them. A reply shows as it is written as a SimpleX live message, updated every two seconds until it is done.

Nostr

--via nostr answers an account’s direct messages on Nostr’s relays (NOSTR_RELAYS, comma-separated; wss://relay.damus.io and wss://nos.lol by default), with its key in NOSTR_PRIVATE_KEY (an nsec1… or hex). Both kinds are read: NIP-17’s private messages, encrypted with NIP-44 and sealed and gift-wrapped as NIP-59 has it, which hide from relays who writes to whom and when; and NIP-04’s older encrypted messages (kind 4), which do not. A reply goes the way its message came; a chat that has not written yet is written to as NOSTR_DM says (nip17, or nip04). A chat is the writer’s key, as an npub1… (--allow npub1…).

Every event is checked against its signature, and a private message against who sealed it, so a relay can withhold messages but not forge them. A private reply goes to the relays its reader names for theirs (kind 10050) as well as to the bridge’s own; an older one to the relays they read (kind 10002). If the account names no relays for its private messages, the bridge names its own, so that others’ apps know where to write. A relay that asks who reads (NIP-42) is answered with the account’s key. The bridge’s place is the time of the last message it took: on starting again, what was written meanwhile is answered.

ntfy

--via ntfy treats each topic in --allow as a chat: polled for what was published to it since the bridge’s place, and answered by publishing to it, tagged robot and sion so that the bridge does not take its own answers for messages (NTFY_URL, https://ntfy.sh by default; NTFY_TOKEN, or NTFY_USER and NTFY_PASSWORD, for a server that asks). ntfy names no writer: whoever may publish to a topic speaks for you there (agent:ntfy-topics), so use a topic of your own on a server that asks who publishes, or a name nobody can guess.

Phone calls

--via phone answers calls to a Twilio number. Twilio hears what the caller says (<Gather input="speech">) and speaks the agent’s reply (<Say>), each step of the call a webhook to --listen, signed with the account’s auth token for the public address Twilio was given (TWILIO_VOICE_URL, the number’s voice webhook). While the agent works, the call waits on two-second pauses, the bridge asked again after each; then the reply is said and the caller heard again. Only the numbers in --allow are answered: other callers are told so, and hung up on. PHONE_LANGUAGE (en-US by default) is the language heard and spoken, PHONE_VOICE one of Twilio’s voices, PHONE_GREETING what a call is greeted with. A caller is named by the number Twilio gives, which can be forged (agent:phone-callers).