Lark / Feishu

Chat SDK adapter for Lark / Feishu, built on the official @larksuiteoapi/node-sdk. WebSocket long-connection event delivery, native cardkit streaming, interactive cards, and reactions.

Vendor-official adapter maintained by Lark / Feishu, not Vercel or Chat SDK contributors. For feature requests, bug reports, and support, file an issue on the adapter's repo.

Install

pnpm add @larksuite/vercel-chat-adapter

Quick start

lib/bot.ts
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createLarkAdapter } from "@larksuite/vercel-chat-adapter";

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.

The adapter auto-detects LARK_APP_ID, LARK_APP_SECRET, and LARK_BOT_USERNAME from environment variables when no explicit config is passed.

Creating a Lark app

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.

scripts/register-app.ts
import {
  registerLarkApp,
  createLarkAdapter,
} from "@larksuite/vercel-chat-adapter";
import qrcode from "qrcode-terminal"; // pnpm add -D 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);

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

Prop

Type

Environment variables

VariableDescription
LARK_APP_IDLark app ID. Overridden by config.appId.
LARK_APP_SECRETLark app secret. Overridden by config.appSecret.
LARK_BOT_USERNAMEBot display name. Overridden by config.userName.

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 works in production.

This means you can run a Lark bot without exposing an HTTP endpoint — the SDK initiates an outbound WebSocket to Lark's servers and receives events through it. Long-running environments (a Node process, a worker, a VM) are the natural fit. Serverless platforms that recycle the process on every request won't keep the connection alive.

Streaming

bot.adapter.stream() uses Lark's native cardkit typewriter API. Chunks emitted from your stream handler are appended directly inside a single card message; no post + edit polling is involved.

await thread.stream(async (controller) => {
  for await (const chunk of llmStream) {
    controller.write(chunk);
  }
});

If the thread has a rootId, the streamed reply is posted as a thread reply (via the SDK's replyTo parameter).

ID encoding

Lark thread IDs encode as lark:{chatId}:{rootId}:

  • chatIdoc_* for both group and p2p chats; ou_* for openDM() placeholders before the first message is delivered.
  • rootId — the message's root_id if it is a reply, otherwise its own 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.

DM detection

Lark's p2p chat IDs share the oc_* prefix with group chats, so isDM() relies on a chat-type cache populated by inbound events. The first DM after a process restart may route through onNewMention until the cache catches up.

Message history

fetchMessages is implemented on top of im.v1.messages.list plus the SDK's normalize() — which covers Lark's 23 native message types and produces the same NormalizedMessage shape as live events.

listThreads is derived client-side by grouping list results on root_id. Paginate carefully for very active chats; there is no native server-side list-threads API.

author.isMe is resolved consistently for historical bot-authored messages, not just live events — the adapter maps the historical entry's app_id back to the bot's open_id via the SDK's botIdentity resolver.

Safety layer

LarkChannel's built-in safety features (stale-message detection, dedup, per-chat queue, text batch) are disabled by the adapter. Chat SDK's per-thread lock plus the state adapter handles message deduplication and subscription consistency — running the SDK's safety on top of Chat SDK's would double-process or drop messages.

Multi-app / multi-tenant

Single-app only at present. A future version may support setInstallation() for multi-tenant fan-out — open an issue if you need it.

Limitations

The following operations are not supported and throw NotImplementedError:

  • handleWebhook — returns HTTP 501; WebSocket transport only.
  • startTyping — Lark has no typing-indicator API.
  • postChannelMessage — Lark requires every message to belong to a chat (no channel-level top-level messages distinct from threads).
  • scheduleMessage, openModal, postEphemeral — not yet implemented.

Feature support

Messaging

FeatureSupported
Post message
Edit message
Delete message
File uploadsVia SDK channel.send
StreamingNative cardkit typewriter
Scheduled messages

Rich content

FeatureSupported
Card formatLark interactive cards
Buttons
Link buttons
Select menusCard select / overflow
TablesMarkdown tables
FieldsCard section fields
Images in cardsImageElement
Modals

Conversations

FeatureSupported
Slash commands
Mentions
Add reactions
Remove reactions
Typing indicator
DMs
Ephemeral messages
User lookup
Parent subject
Native client
Custom API endpoint

Message history

FeatureSupported
Fetch messages
Fetch single message
Fetch thread info
Fetch channel messages
List threadsClient-side grouping
Fetch channel info
Post channel message