New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@larksuite/vercel-chat-adapter

Package Overview
Dependencies
Maintainers
9
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@larksuite/vercel-chat-adapter

Lark / Feishu adapter for vercel/chat (chat-sdk.dev)

latest
Source
npmnpm
Version
0.3.0
Version published
Maintainers
9
Created
Source

@larksuite/vercel-chat-adapter

npm version npm downloads

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

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"; // `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),
});

// Stash these somewhere durable (env vars, secrets manager, …)
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

OptionRequiredDescription
appIdYesLark app ID. Auto-detected from LARK_APP_ID
appSecretYesLark app secret. Auto-detected from LARK_APP_SECRET
domainNoOpen-platform domain: "lark" (open.larksuite.com), "feishu" (open.feishu.cn, default), or an http(s):// origin for private deployments. Auto-detected from LARK_DOMAIN
userNameNoBot display name (defaults to LARK_BOT_USERNAME or "bot")
loggerNoLogger 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   # or "feishu" (default)

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) => {
  // event.actionId === "approve", event.value === "42"
  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

FeatureSupported
Post messageYes
Edit messageYes
Delete messageYes
File uploadsVia SDK channel.send({file}) — wrapped through postMessage
StreamingYes — native cardkit typewriter

Rich content

FeatureSupported
Card formatLark card JSON 2.0 (schema: "2.0") — see Interactive cards
ButtonsYes — callback buttons routed to bot.onAction(id)
Link buttonsYes — open_url buttons
Select menusYes — select_static; RadioSelect is rendered as a dropdown too
TablesYes — native Lark table component
FieldsYes — two-column column_set rows
Images in cardsYes — downloaded and re-uploaded as img_key (needs the im:resource scope)
Text / Divider / LinkYes — markdown / hr / markdown link
ModalsNo

Conversations

FeatureSupported
Slash commandsNo
MentionsYes
Add reactionsYes
Remove reactionsYes
Typing indicatorNo (Lark has no API)
DMsYes
Ephemeral messagesNo (roadmap)

Message history

FeatureSupported
Fetch messagesYes (via im.v1.messages.list + SDK normalize())
Fetch single messageYes
Fetch thread infoYes
Fetch channel messagesYes
List threadsYes (grouped by root_id client-side)
Fetch channel infoYes
Post channel messageNo

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

Keywords

chat

FAQs

Package last updated on 14 Sep 2026

Related posts