lark-channel-molecule
Bridge daemon connecting one Lark / Feishu bot to one Molecule
workspace (the target agent). It runs in one of two modes
(--mode, default poll):
poll(below): the daemon is itself a Molecule workspace — registereddelivery_mode="poll", so no public endpoint is needed on either side: the Lark side rides the SDK's WebSocket long connection, the Molecule side rides the platform activity poll.daemon: the connected workspace spawns the bridge as its own channel daemon (runtime#216contributes.daemons— see Daemon mode below). No second workspace, no self-registration.
Lark / Feishu cloud Molecule platform
┌──────────────────────┐ ┌──────────────────────────────┐
│ DM / group @mention │ │ target workspace (agent) │
└──────────┬───────────┘ └────────▲──────────┬──────────┘
│ ws long connection │ peer A2A │ reply
│ im.v1.message.receive_v1 │ POST /workspaces/
┌──────────▼──────────────────────────────┐ │ {target}/a2a
│ lark-channel-molecule daemon ├──┘ │
│ (its own workspace, delivery_mode=poll)◄─────────────┘
└──────────┬──────────────────────────────┘ inbox poll:
│ im.v1 message reply/create /workspaces/{id}/activity
┌──────────▼───────────┐ ?type=a2a_receive
│ originating chat │
└──────────────────────┘
- Lark → Molecule: a DM (or a group message that @mentions the bot)
is forwarded into the target workspace via
send_a2a_message, with the Lark metadata (chat_id, senderopen_id,message_id, brand) carried in the payload header. - Molecule → Lark: the target's reply lands on the bridge's own
inbox poll as a
peer_agentmessage and is delivered back into the originating chat as a threaded reply. Unsolicited target messages go to the session-default chat (most recently active), elseLARK_DEFAULT_CHAT_ID, else are logged and dropped. - Dedup: a bounded LRU of processed Lark
message_ids absorbs Lark's event redelivery. It is persisted in the session store (sessions.json, alongside the in-flight turn), so a daemon restart doesn't re-forward a message it already relayed.
Install
pip install lark-channel-molecule # + '[qr]' for QR rendering
# molecules-workspace-runtime comes from the org registry:
# PIP_INDEX_URL=https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/
Onboarding (create the Lark/Feishu app)
lark-channel-molecule connect
Runs lark-oapi's scan-to-create device grant (RFC 8628): it prints a
verification URL — and, with the qr extra installed, an ASCII QR on
stdout plus a PNG under the state dir — which you open/scan in the Lark
or Feishu mobile app and approve. The flow is dual-brand: it starts on
the Feishu accounts domain and switches automatically when the poll
reports tenant_brand=lark. On completion the freshly-minted bot's
{app_id, app_secret, tenant_brand} is persisted to
$LARK_CHANNEL_STATE_DIR/credentials.json (mode 0600, default dir
~/.lark-channel-molecule).
In the platform integration the AGENT runs this same flow and posts the QR image into chat for the user to scan; this CLI is the operator-facing equivalent.
Daemon mode (workspace-launched)
--mode daemon is the channels-as-plugins shape (Lark RFC §2.4): the
connected workspace itself spawns the bridge via the plugin
manifest's contributes.daemons entry (runtime#216) — the runtime
starts it after boot, restarts it on crash, and kills it with the
workspace. Differences from poll mode:
- No self-registration, no heartbeat — the host workspace is
already registered; the daemon inherits its identity
(
WORKSPACE_ID,MOLECULE_WORKSPACE_TOKEN,PLATFORM_URLfrom the workspace env;LARK_*from workspace secrets). - Target defaults to the host workspace —
MOLECULE_TARGET_WORKSPACE_IDmay be omitted. - Replies: a synchronous a2a response (push-delivery hosts) is delivered directly; a queued ack falls back to the inbox-poll path. Known limit: a queued reply for target==host is dropped by the runtime's self-echo guard — covered by the sync path today; the runtime#215 PR-2 event socket is the durable fix.
- Bootstrap: the plugin dir is delivered as files (never
pip-installed), so the manifest's daemon command runs
daemon-bootstrap.sh, which idempotently installs the pinned wheel from the org registry andexecspython3 -m lark_channel_molecule.daemon --mode daemon.tests/test_manifest_daemon.pykeeps plugin.yaml / pyproject / the bootstrap pin in sync.
Env contract
| Variable | Required | Meaning |
|---|---|---|
WORKSPACE_ID |
yes | poll: UUID of the channel workspace this daemon registers + polls as · daemon mode: the HOST workspace (inherited) |
PLATFORM_URL |
yes | https://<tenant>.moleculesai.app |
MOLECULE_WORKSPACE_TOKEN |
yes | bearer token for the channel workspace |
MOLECULE_TARGET_WORKSPACE_ID |
poll: yes · daemon: no | UUID of the agent workspace turns are relayed into (daemon mode defaults to the host workspace) |
LARK_APP_ID / LARK_APP_SECRET |
no* | Lark bot credentials (env wins over the creds file) |
LARK_BRAND |
no | feishu (default) or lark — overrides the file's tenant_brand |
LARK_BOT_OPEN_ID |
no | the bot's own open_id; when set, a group message forwards only if one of its mentions[].id.open_id equals it. Unset ⇒ any-mention fallback, correct under the fleet-default im:message.group_at_msg scope (Lark then only delivers group messages that @mention the bot). Tenants granted the broader im:message.group_msg scope must set this. |
LARK_DEFAULT_CHAT_ID |
no | fallback chat for unsolicited target messages |
LARK_CHANNEL_STATE_DIR |
no | state dir (creds, sessions.json, QR PNG); default ~/.lark-channel-molecule |
* required unless credentials.json exists (written by connect).
Missing required vars fail loudly at startup, listing every missing
name (exit 2).
Brand axis
brand ∈ {feishu, lark} selects every domain: the OpenAPI base
(https://open.feishu.cn vs https://open.larksuite.com — REST + ws)
and the accounts domain for the device grant. lark-oapi defaults to
Feishu, so the bridge always passes the domain explicitly, derived from
the credential tuple (app_id, app_secret, brand).
lark_channel_molecule/brand.py owns the mapping; a test greps the
package to keep domains out of every other module.
systemd unit example
[Unit]
Description=Lark channel bridge for Molecule workspace
After=network-online.target
[Service]
Environment=WORKSPACE_ID=<channel-ws-uuid>
Environment=PLATFORM_URL=https://<tenant>.moleculesai.app
Environment=MOLECULE_WORKSPACE_TOKEN=<token>
Environment=MOLECULE_TARGET_WORKSPACE_ID=<agent-ws-uuid>
# creds file from `lark-channel-molecule connect` is picked up from
# ~/.lark-channel-molecule/credentials.json (or set LARK_APP_ID/SECRET here)
ExecStart=/usr/local/bin/lark-channel-molecule --log-level INFO
Restart=on-failure
RestartSec=5
User=molecule
[Install]
WantedBy=multi-user.target
Limitations (MVP)
- Text-only turns — non-text Lark messages (images, files, cards) are logged and skipped; the reply direction is plain text.
- Global turn serialization — at most one in-flight turn to the
target across ALL chats (one turn at a time per bridge). Peer A2A
returns no task/queue id to correlate replies on, so the bridge keeps
a single persisted in-flight slot and attributes every target reply
to it — cross-chat reply misdelivery is structurally impossible.
Waiting messages queue in arrival order (per-chat order preserved),
in memory. Throughput: the bridge advances one turn per target
reply — or per
--turn-timeout-secsexpiry (default 600), which releases the slot and lets the queue advance — so a slow turn in one chat delays every chat. Fine for a single-team bot; wrong shape for high-fanout deployments. The durable fix is an a2a correlation id echoed by the platform (see the follow-up issues, #2). - Group messages relay only when the bot is @mentioned. Without
LARK_BOT_OPEN_IDthe check is "the message carries any @mention" — exact under the fleet-defaultim:message.group_at_msgevent scope (Lark only delivers group messages that @mention the bot), but over-broad for tenants grantedim:message.group_msg(all group traffic): those deployments must setLARK_BOT_OPEN_IDso only true bot-mentions forward.
Follow-ups
Tracked as repo issues #1–#4 — see the issue tracker; not duplicated here.