@larksuite/vercel-chat-adapter

Lark (Feishu) adapter for Chat SDK. Configure with a self-build app and WebSocket long-connection event subscription.
Built on top of LarkChannel from @larksuite/channel, which provides the underlying transport, event normalization, message send/stream, and safety primitives.
Installation
pnpm add @larksuite/vercel-chat-adapter
Usage
The adapter auto-detects LARK_APP_ID, LARK_APP_SECRET, and LARK_BOT_USERNAME from environment variables:
import { Chat } from "chat";
import { createLarkAdapter } from "@larksuite/vercel-chat-adapter";
import { createMemoryState } from "@chat-adapter/state-memory";
const bot = new Chat({
userName: "mybot",
adapters: {
lark: createLarkAdapter(),
},
state: createMemoryState(),
});
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await thread.post(`You said: ${message.text}`);
});
bot.onDirectMessage(async (thread, message) => {
await thread.post(`Got your DM: ${message.text}`);
});
await bot.initialize();
bot.initialize() opens the Lark WebSocket connection and keeps it alive until bot.shutdown() is called. The process stays alive as long as the WS is open, so no separate server is needed in a long-running environment.
Creating a Lark app
Option A — scan-to-create (recommended)
registerLarkApp drives Lark's official scan-to-create flow: the SDK
generates a one-time URL, you render it as a QR code, the user scans with
the Lark mobile app and approves, and you get back client_id /
client_secret — with the permissions and event subscriptions this adapter
needs already configured.
import { registerLarkApp, createLarkAdapter } from "@larksuite/vercel-chat-adapter";
import qrcode from "qrcode-terminal";
const { client_id, client_secret } = await registerLarkApp({
onQRCodeReady: ({ url }) => {
console.log("Scan this QR with your Lark mobile app:");
qrcode.generate(url, { small: true });
},
onStatusChange: ({ status }) => console.log("status:", status),
});
console.log("LARK_APP_ID=", client_id);
console.log("LARK_APP_SECRET=", client_secret);
const adapter = createLarkAdapter({
appId: client_id,
appSecret: client_secret,
});
You only need to run this once. Persist the returned credentials and feed
them back via LARK_APP_ID / LARK_APP_SECRET in subsequent runs.
Option B — create via developer console
Go to the developer console and create an Intelligent Agent app:
Grab the app's client_id and client_secret and pass them as appId / appSecret (or set LARK_APP_ID / LARK_APP_SECRET).
Configuration
appId | Yes | Lark app ID. Auto-detected from LARK_APP_ID |
appSecret | Yes | Lark app secret. Auto-detected from LARK_APP_SECRET |
domain | No | Open-platform domain: "lark" (open.larksuite.com), "feishu" (open.feishu.cn, default), or an http(s):// origin for private deployments. Auto-detected from LARK_DOMAIN |
userName | No | Bot display name (defaults to LARK_BOT_USERNAME or "bot") |
logger | No | Logger instance (defaults to ConsoleLogger("info", "lark")) |
appId / appSecret can be provided via config or the matching env var — whichever is present wins.
Environment variables
LARK_APP_ID=cli_xxxxxxxx
LARK_APP_SECRET=xxxxxxxxxxxxxxxx
LARK_BOT_USERNAME=mybot
LARK_DOMAIN=lark
Lark-brand apps (open.larksuite.com)
Apps created on the Lark developer console (open.larksuite.com) live on a
different domain from Feishu apps. Point the adapter at it with domain, or
set LARK_DOMAIN=lark in the environment — otherwise every request fails
with 1000040351 Incorrect domain name.
const adapter = createLarkAdapter({
appId: process.env.LARK_APP_ID,
appSecret: process.env.LARK_APP_SECRET,
domain: "lark",
});
If you registered the app with registerLarkApp, the result tells you which
brand it belongs to: user_info.tenant_brand === "lark" means you need
domain: "lark". Private deployments can pass a full origin such as
https://open.example.com instead of a brand name.
Interactive cards
Cards built with Chat SDK components are rendered as Lark interactive cards
(card JSON 2.0). Buttons and select menus call back into bot.onAction, and
thread.edit with a card replaces the card in place.
import { Card, Actions, Button, Text } from "chat";
bot.onNewMention(async (thread) => {
await thread.post(
<Card title="Deploy request">
<Text content="Ship build 42 to production?" />
<Actions>
<Button id="approve" label="Approve" value="42" style="primary" />
<Button id="reject" label="Reject" style="danger" />
</Actions>
</Card>
);
});
bot.onAction("approve", async (event) => {
await event.thread?.edit(
event.messageId,
<Card title="Approved ✅">
<Text content="Build 42 is on its way to production." />
</Card>
);
});
Plain object cards ({ type: "card", children: [...] }) and
thread.post({ card, fallbackText }) work the same way; fallbackText becomes
the card's preview summary and is what gets sent when nothing in the card can
be rendered.
Before clicks reach your bot, subscribe the app to the card.action.trigger
callback in the developer console (Events & callbacks → Callback subscription,
long-connection mode) and publish a new app version. Without it, cards render
but clicks go nowhere.
Notes:
- Element mapping:
Text → markdown; Divider → hr; Actions children
sit side by side in a column_set; Button → callback button; LinkButton →
open_url button; Select / RadioSelect → select_static (Lark has no
radio group outside forms); Fields → two-column rows; Table → Lark table;
Link → markdown link; title / subtitle → card header.
- Images are fetched from the given URL and uploaded with
im.v1.image.create,
so the app needs the im:resource scope. Only public http(s) origins are
fetched (private/loopback/link-local addresses are refused), with a 10 MB size
cap, 15 s timeout and at most 10 images per card. A rejected or failed image
degrades to its alt text (plus a link when the URL itself was acceptable); the
rest of the card is still sent.
- Text is Lark markdown:
**bold**, links and markup such as
<at id=all></at> are interpreted as-is. Escape text that comes from users.
- Repeat clicks on the same button by the same operator are de-duplicated by
the channel (the button
value is truncated to 128 characters in that key).
- Not supported: modals / form containers, input fields, external selects,
date or person pickers, and callback responses (toast / in-place card update on
click) — use
thread.edit for the latter.
Features
Messaging
| Post message | Yes |
| Edit message | Yes |
| Delete message | Yes |
| File uploads | Via SDK channel.send({file}) — wrapped through postMessage |
| Streaming | Yes — native cardkit typewriter |
Rich content
| Card format | Lark card JSON 2.0 (schema: "2.0") — see Interactive cards |
| Buttons | Yes — callback buttons routed to bot.onAction(id) |
| Link buttons | Yes — open_url buttons |
| Select menus | Yes — select_static; RadioSelect is rendered as a dropdown too |
| Tables | Yes — native Lark table component |
| Fields | Yes — two-column column_set rows |
| Images in cards | Yes — downloaded and re-uploaded as img_key (needs the im:resource scope) |
| Text / Divider / Link | Yes — markdown / hr / markdown link |
| Modals | No |
Conversations
| Slash commands | No |
| Mentions | Yes |
| Add reactions | Yes |
| Remove reactions | Yes |
| Typing indicator | No (Lark has no API) |
| DMs | Yes |
| Ephemeral messages | No (roadmap) |
Message history
| Fetch messages | Yes (via im.v1.messages.list + SDK normalize()) |
| Fetch single message | Yes |
| Fetch thread info | Yes |
| Fetch channel messages | Yes |
| List threads | Yes (grouped by root_id client-side) |
| Fetch channel info | Yes |
| Post channel message | No |
Thread ID format
Lark thread IDs encode as lark:{chatId}:{rootId}:
chatId — oc_* for group/p2p chats, ou_* for openDM() placeholders
rootId — root_id if the message has one (it's a reply); else message_id (the message is its own root)
Lark's native thread_id (topic containers, omt_*) is not used as the rootId segment — it's a topic container ID, not a message ID, and can't be used as replyTo on the send API.
Notes
- Transport: WebSocket only.
handleWebhook() returns HTTP 501. Webhook transport is on the roadmap; for now, Lark's "long-connection" mode is the intended delivery channel and it works fine in production.
- Safety layer:
LarkChannel's message-level stale detection, per-chat queue and text batching are disabled — Chat SDK's lock + state adapter handles message deduplication and subscription. Card-click deduplication stays on in the channel: a repeat click on the same button by the same operator is collapsed before it reaches bot.onAction.
- DM detection: Lark p2p chat IDs share the
oc_* prefix with group chats, so isDM() relies on a cache populated by inbound events. The first DM after a process restart may route through onNewMention until the cache catches up.
- Historical bot messages:
author.isMe is resolved consistently for bot-authored history entries, not just live events.
- listThreads: derived client-side from
im.v1.messages.list. Paginate carefully for very active chats.
- Multi-app / multi-tenant: single-app only. A future version may support
setInstallation() for multi-tenant fan-out.
License
MIT