WeChat ClawBot¶
Drive Nova from a WeChat conversation. WeChat is the message surface; Nova keeps running on your machine and does the work.
The bridge lives in weixin-clawbot/ and is a Node
process, because WeChat's official channel ships as an npm package. It talks to
Nova over the same HTTP/SSE surface the web frontend uses.
Why a bridge rather than a Nova plugin¶
WeChat's ClawBot plugin reaches a self-hosted agent through
@tencent-weixin/openclaw-weixin, and the only supported integration point is a
Node package. Reimplementing the channel in Python would mean tracking a private,
undocumented protocol with no upstream changelog. The bridge keeps that risk in a
process that does nothing but translate messages, and leaves Nova untouched.
What runs where¶
WeChat app ──> Tencent iLink ──> bridge (Node) ──HTTP/SSE──> nova serve ──> LLM
│
└── weixin-agent-sdk (long poll, media, QR login)
Nothing listens on an inbound port. The bridge dials Nova on loopback, and the
WeChat side is an outbound long poll: the SDK repeatedly calls
ilink/bot/getupdates, which Tencent holds open for 35 seconds and answers early
when a message arrives. That direction is what makes a local agent reachable
without port forwarding.
Setup¶
Enable the ClawBot plugin in WeChat first (Settings -> Plugins), then start Nova and bind your account:
nova serve # in one shell
cd weixin-clawbot
npm install
npm run login # prints a QR code to scan
npm start
npm start scans on its own when no account is bound, so the standalone login
step is for keeping the QR prompt out of the message-loop terminal or for
rebinding a different account.
A dedicated agent is worth having so WeChat turns get their own model, sessions and persona:
curl -X POST http://127.0.0.1:8765/api/agents \
-H 'content-type: application/json' \
-d '{"key":"weixin","name":"WeChat","model":"<model>","provider":"<provider>"}'
NOVA_AGENT_KEY=weixin npm start
NOVA_AGENT_KEY names a record in Nova's agents table. The bridge sends no
provider or model of its own, so that record supplies them; an unknown key fails
the turn with Agent '<key>' has no configured provider/model. It is also the
agent a conversation starts on, and setting it pins the bridge to that one
persona.
Several agents on one bot¶
WeChat allows one ClawBot per account. A second QR does not add a bot, it replaces
the first -- the protocol returns binded_redirect, and the superseded bot's
token is rejected with errcode -14 from then on. So the agent is selected
in-band instead:
Indices resolve against a key-sorted list rather than Nova's response order, which is sorted by display name -- otherwise renaming an agent would shift every index and silently redirect the user somewhere else. The switch reply names the key it landed on, so a miscount is caught before a message goes to the wrong agent. A key is still accepted as an argument.
Each agent is reached under the same conversationId (the sender's
ilink_user_id), so the agent half of the session key is what stops two personas
sharing one Nova session and reading each other's history. The outbox cannot be
shared for the same reason, and stays a subdirectory per agent. Switching back
resumes the earlier agent where it left off.
Two things are deliberately not offered. Sub-agents are excluded, because
build_agent applies a record's posture only when is_sub_agent is true, so a
read_only sub-agent reached over the primary chat path would hold the full
toolset while claiming to be restricted. And a switch is refused while a turn is
running or an approval is unanswered, since both would move the user's pending
answer to an agent with no idea what it is about.
Sessions created before an agent's model changed keep running on the model they started with (request beats session beats agent), which is why switching models later does not retroactively move an in-flight conversation.
Configuration keys are documented in
weixin-clawbot/README.md.
How a turn works¶
- WeChat delivers a message to the SDK's long poll.
chat()on the bridge acknowledges immediately and returns.- The turn runs detached against
POST /api/chat/stream. - SSE frames are collapsed into WeChat messages; the final answer is pushed with
bot.sendMessage().
Step 2 is not a style choice. The SDK's monitor awaits each message in turn:
A chat() that waited for its turn would stall the whole loop, making /stop
and approval replies unreachable for exactly as long as the task runs.
Frame mapping¶
/api/chat/stream emits AI SDK v3 parts. Nova's own extensions arrive as
data-nova-* parts whose fields sit under a data object, so they must be read
from there rather than the top level.
| Frame | Bridge reaction |
|---|---|
data-nova-session |
Learn the session id, bind it to the conversation |
text-delta |
Accumulate into the final answer |
tool-input-available |
One progress line: tool name and a short argument preview |
data-nova-tool-error |
Mark the tool as failed in progress |
data-nova-approval-required |
Ask the user to allow or deny |
data-nova-approval-resolved |
Drop the pending prompt (a resumed stream replays both) |
abort / error |
Report interruption or failure |
[DONE] |
End the stream |
Approval in a chat window¶
Nova classifies shell commands and holds a turn on anything dangerous, waiting
for POST /api/chat/approve. A chat client has no dialog to click, so the bridge
renders the prompt and translates a typed reply:
y/n answer the current request; a also adds the command to the session's
allowlist. The answer is only interpreted as an approval while one is actually
pending, so a stray y is treated as an ordinary prompt.
The turn has no separate deadline. If Nova has already given up, the answer comes
back 404 and the user is told the request expired.
Interrupt semantics¶
/stop does two things, because they are not equivalent:
- aborts the SSE fetch, which stops the bridge from reading; and
- calls
POST /api/chat/interrupt, which actually stops the agent.
Dropping the socket alone only parks the turn as detached on Nova's side, leaving the agent running.
Session mapping¶
A WeChat conversation is long-lived, so its Nova session has to outlive the
bridge. The mapping is persisted to ~/.nova/weixin-bridge/sessions.json, keyed
by <agentKey>/<conversationId> -- both halves, because one agent serves every
sender and each sender needs a session of their own.
One file is enough: the key already partitions the agents, so a file per agent
held a single entry apiece. Any sessions.<name>.json left by an earlier
per-agent layout is folded in and removed at startup, the shared file winning any
collision because it is what the running bridge writes.
The mapping cannot be derived. Agent._resolve_session reuses a supplied
session_id only when that session already exists and mints a fresh id
otherwise, so the bridge learns the real id from the first
data-nova-session frame and stores it.
Sending files¶
Two paths, neither of which depends on the model phrasing a request the bridge has to parse out of prose.
/send <absolute path>sends one file immediately.- Anything the agent leaves in
~/.nova/weixin-bridge/outbox/is sent at the end of the turn and then removed, so the answer arrives first and the file last. Teaching the agent this one convention (in its persona file or a skill) is enough to make it hand over deliverables.
Long replies use the same mechanism: past NOVA_MAX_TEXT_CHARS the text is
written to disk and attached rather than truncated mid-sentence.
Because a chat surface is an exfiltration path, a bare path is never sufficient.
resolveSendable refuses anything that is not a plain readable file, exceeds
NOVA_MAX_FILE_MB, or falls outside NOVA_WORKSPACE_DIR, NOVA_BRIDGE_OUTBOX,
NOVA_BRIDGE_SPILL_DIR and NOVA_SEND_ROOTS. Symlinks are resolved before the
containment check, so a link inside the workspace cannot reach /etc/passwd, and
directories are refused outright. Refusals are reported in chat rather than
silently dropping the upload.
NOVA_SEND_ROOTS is how a user opts directories in for sending -- say
~/Desktop or ~/Documents/sheets -- and is deliberately separate from
NOVA_WORKSPACE_DIR: widening what can leave the machine must not widen what the
agent itself can read or write.
Plain-language requests ("send me the pdf on my Desktop") only become attachments
if the agent has been told about the outbox convention. A path in the assistant's
prose is not parsed into an upload; the bridge sends what /send names or what the
outbox contains.
Keeping the machine reachable¶
A bridge that sleeps is a bridge that misses messages, and macOS idles an
unplugged laptop quickly. NOVA_KEEP_AWAKE=1 spawns caffeinate -i -w <pid> at
startup, which holds an IdleSystemSleepPrevented assertion for exactly as long
as the bridge process lives. -i blocks idle sleep only, so the display still
sleeps; the CPU and network stay up.
Scoping the assertion to the pid is what keeps this honest. On exit -- clean,
crashed, or restarted -- the watcher releases it, so the machine cannot be left
awake by an assertion nobody is tracking. /status reports whether it is held,
which is the difference between knowing and assuming.
Lid-close is not covered. Clamshell sleep is a hardware policy that power assertions cannot override, and only Apple's documented conditions -- AC power plus an external display and input devices -- keep a closed lid awake. Long-term unattended use belongs on a machine that does not sleep.
Several senders¶
ClawBot is one person's entry in one WeChat account, and there is no way to share
it: the bot is not in the contact list, cannot be forwarded, and the protocol has
no group chat or third-party subscriber. Whoever scans the QR code is the only
sender, so the QR code is the access control -- NOVA_ALLOWED_SENDERS is defence
in depth, not the primary gate.
State is keyed per sender regardless. conversationId is the sender's
ilink_user_id, every piece of state is keyed by it, and each sender would get
its own Nova session, so their conversations stay reviewable in Nova's session
list rather than merged into one.
One asymmetry is worth knowing before that day comes. Inbound messages are
addressed to whoever sent them -- the SDK replies to from_user_id -- but
Bot.sendMessage() targets the user id captured at login, because the Bot
instance holds it as a private field with no per-call override. This bridge runs
its turns detached and pushes every answer that way, so a second sender's reply
would land on the account that scanned. The context tokens the SDK needs to route
correctly are already stored per (accountId, userId), so the gap is in that one
abstraction rather than in the protocol.
Two properties keep that safe, both aimed at the outbox, which an agent writes into without knowing which conversation it serves:
- One turn at a time. Overlapping turns would let one sender's drain pick up another's file and delete it, and the agents share one workspace besides. Turns are serialised across conversations, and a second sender is turned away instead of queued.
- A turn claims only what appeared after it started.
/stopreturns before the drain runs, so an interrupted turn leaves its file behind; the next sender must not inherit it, and nothing is deleted that was not sent.
NOVA_ALLOWED_SENDERS closes the larger door: whoever can message the bot can
otherwise drive tools on the machine and approve their own dangerous commands. An
unlisted sender gets a fixed reply without reaching the agent at all.
Limits worth knowing¶
- Proactive push expires.
sendMessagerequires acontext_token, which exists only after an inbound message and is valid for roughly 24 hours. Nova's background work -- sub-agent completions, scheduled wake-ups -- can therefore deliver to WeChat only while you have been talking to it recently. - Images only.
build_user_messagehandlesimageanddocumentattachments and silently drops everything else. The bridge forwards images and drops other media rather than approximating it. - Long replies become files. Over
NOVA_MAX_TEXT_CHARS, the text is written to disk and sent as an attachment, since a chat bubble cannot be trusted to render it intact. - Everything transits Tencent. Messages and media (AES-128-ECB) pass through Tencent's iLink service. Keep sensitive work elsewhere.
- One ClawBot per WeChat account. A WeChat product limit, not a bridge one: a
new scan replaces the previous bot, and its token stops working immediately. Use
/agentto reach a second Nova agent, not a second scan. A failed session (errcode -14) also puts the SDK into a one-hour cooldown, which restarting the bridge clears. - Not shareable. One bot is one person's entry, reachable only through the QR code. There is no group chat and no third-party subscription in the protocol, so "let my friends use my agent" is not something the bridge can offer -- and if the protocol ever adds one, proactive replies would still need routing per sender rather than to the scanning account.