Lark / Feishu
Lark IM messaging via tenant_access_token, with optional AES-256-CBC-encrypted webhook events.
🟡 Outbound-ready, inbound supported, covers both mainland Feishu (
open.feishu.cn) and international Lark (open.larksuite.com). Inbound webhook handles all three of Lark's security modes: verification token, encryption, and signed HMAC.
What you get
- Outbound text messaging via
POST /open-apis/im/v1/messages?receive_id_type=chat_id. Tenant access tokens are cached for 2 hours with a 5-minute refresh headroom, so high-volume senders do not hit the per-app token rate limit. - Inbound webhook events at
/webhooks/lark. Lark lets the operator pick any combination of three security modes (verification token, encrypt key, HMAC signature). Minara honours whichever modes you enable in the developer console. - Domain-aware routing.
LARK_DOMAINselects mainland Feishu or Lark international. Both surfaces share the same API shape. - Text only in this PR. Rich cards, images, and files are a future enhancement, the foundation (tenant token cache, signed webhook) lands now.
- 30 000-char limit. Longer text is truncated.
Setup
1. Create an app
- Sign in to Lark Developer Console (or Lark international)
- Create a Custom App. Note the App ID (starts with
cli_) and App Secret - Under "Permissions & Scopes", grant
im:message:send_as_botand, for inbound, the message-receive scope your bot needs (typicallyim:message) - In "Event Subscriptions", set the request URL to
https://<your-host>/webhooks/lark. Copy the Verification Token and (optional) Encrypt Key
2. Find a default chat_id
Send a message in the target chat (1:1 or group), then call
POST /open-apis/im/v1/messages/list from the developer console or
use a quick curl with your tenant token to extract a chat_id
(format: oc_xxxxxxxxxxxxxxxx). Save it as LARK_DEFAULT_CHAT_ID.
3. Configure Minara
minara auth messaging add
# pick `lark` from the list; paste app id, app secret, chat id,
# verification token (and encrypt key if you enabled encryption).Or directly in your project .env file:
LARK_APP_ID=cli_xxxxxxxxxxxxxxxx
LARK_APP_SECRET=<opaque secret>
LARK_DEFAULT_CHAT_ID=oc_xxxxxxxxxxxxxxxx
LARK_VERIFICATION_TOKEN=<verification token>
LARK_ENCRYPT_KEY=<encrypt key, optional>
LARK_DOMAIN=open.feishu.cn # or open.larksuite.com4. Test
minara auth messaging test larkInbound webhook
Configure the Lark Event Subscription URL to:
https://<your-host>/webhooks/larkThree security modes layer on top of each other:
| Mode | Env var | What it does |
|---|---|---|
| Verification token | LARK_VERIFICATION_TOKEN | Decoded payload's top-level token field must match. Used in plaintext mode and after decryption. |
| Encryption | LARK_ENCRYPT_KEY (optional) | When set, body arrives as {encrypt: "<base64>"} and is decrypted with AES-256-CBC, key = SHA256(encrypt_key), IV = first 16 bytes of the key. |
| Signature | X-Lark-Signature header (always optional) | SHA-256 over timestamp + nonce + encrypt_key + body hex, presented in the header. Enable in the console for an extra integrity check. |
URL verification handshake: Lark sends {type:"url_verification", challenge:"..."},
Minara replies with {challenge} after checking the verification
token. A wrong token returns 403.
Limits & caveats
- Tenant token rate limits. Lark enforces ~100 token-fetch calls per minute per app. The 2 h cache stays well under this.
contentmust be a JSON-encoded string. This is a Lark API quirk: even text messages sendcontent: JSON.stringify({text: "..."}). Minara handles this for you.- No card interactions yet. Button-click callbacks on interactive cards arrive at a different webhook event type and are not parsed.
Troubleshooting
"Test message returns code 99991663"
- Tenant access token failed to refresh. Check
LARK_APP_IDandLARK_APP_SECRETagainst the developer console.
"Inbound webhook returns 401"
- If you enabled the encryption mode in the console but did not set
LARK_ENCRYPT_KEY, every inbound POST fails decryption. Either disable encryption in the console or set the env var. - If you enabled signature mode, verify the
LARK_ENCRYPT_KEYyou saved matches what the console shows (it doubles as the signing input).
"content field is required (400)"
- Your code is calling
send_messagewithtextset to empty string. Lark rejects empty bodies.
Reference
- Env vars:
LARK_* - Outbound:
apps/agent/src/messaging/lark.ts - Inbound spec:
apps/agent/src/messaging/inbound/specs/lark.ts - Lark Open Platform: open.feishu.cn
- Encryption recipe: open.feishu.cn / Encryption case