Feishu/Lark CardKit streaming-card helper — typewriter-style card updates for AI bots in a few lines.
Project description
feishu-streaming-card
Minimal helper for Feishu / Lark CardKit streaming cards — give your AI bot a native typewriter effect with a coloured final card swap-in, in ~10 lines.
import lark_oapi as lark
from feishu_streaming_card import StreamingCard
client = lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).build()
async with await StreamingCard.open(client=client, chat_id=USER_OPEN_ID) as card:
buffer = ""
async for delta in your_llm.stream(...):
buffer += delta
await card.push(buffer)
# context-manager exit auto-swaps the streaming card for a coloured rich card
That's it. No need to deal with card_id, sequence, throttling, or the dual-call quirks of cardkit.v1.card.update vs cardkit.v1.card.settings.
Why this exists
Feishu's CardKit streaming gives you a beautiful typewriter effect in chat — but the SDK requires you to:
- Create a card entity (
cardkit.v1.card.create) - Send the entity as an interactive message (
im.v1.message.create) - Push cumulative text to the card element on every delta, with a strictly monotonic
sequence(cardkit.v1.card_element.content) - Throttle pushes under 10 QPS/card or get rate-limited
- Call
cardkit.v1.card.update(or.settings) at the end to leave streaming mode and swap in a richer final layout — otherwise the chat preview stays stuck on[生成中...]and the card can't be forwarded
This library does all five for you. You just push cumulative text and exit the context manager.
Install
pip install feishu-streaming-card
Requires Python 3.9+ and lark_oapi >= 1.4.
Setup checklist
In the Feishu open-platform admin console:
- Enable the bot capability
- Grant scopes:
im:message:send_as_bot— send cards as the botim:message— receive / reply to messages (only needed if you reply to incoming)cardkit:card:write— required for streaming cards
Without cardkit:card:write, StreamingCard.open() raises StreamingCardError.
The Feishu client must be version 7.20+ — older clients only see the card title and a "please upgrade" placeholder.
Quick start
1) Plain text stream
import asyncio, os, lark_oapi as lark
from feishu_streaming_card import StreamingCard, StreamingCardConfig
async def main():
client = (
lark.Client.builder()
.app_id(os.environ["FEISHU_APP_ID"])
.app_secret(os.environ["FEISHU_APP_SECRET"])
.build()
)
cfg = StreamingCardConfig(title="Bot", final_header_template="green")
async with await StreamingCard.open(
client=client,
chat_id=os.environ["FEISHU_RECEIVE_ID"], # open_id or chat_id
receive_id_type="open_id", # or "chat_id"
config=cfg,
) as card:
# push cumulative — NOT the delta. The library handles throttling.
buffer = ""
for ch in "你好,我是一个流式 AI 卡片 demo":
buffer += ch
await card.push(buffer)
await asyncio.sleep(0.03)
See examples/quickstart.py.
2) Drive directly from an async for LLM stream
from feishu_streaming_card import stream_into_card
card = await stream_into_card(
client=feishu_client,
chat_id=open_id,
deltas=your_async_llm_stream(),
)
print(card.message_id) # use this to thread follow-ups
See examples/with_openai.py for a real OpenAI integration.
Public API
| Symbol | Type | Purpose |
|---|---|---|
StreamingCard.open(...) |
classmethod (async) | Create + deliver the card; returns ready-to-use instance |
StreamingCard.push(text, *, force=False) |
async method | Push cumulative text; throttled under min_push_interval_s |
StreamingCard.close(*, final_text=None, final_card=None) |
async method | Flush + swap in the rich final card; idempotent |
StreamingCard.message_id / .card_id / .chat_id / .closed |
properties | Inspect state |
stream_into_card(...) |
async function | One-shot helper: open → drive → close |
StreamingCardConfig |
dataclass | Tuning knobs (see below) |
build_streaming_card_json(...) |
function | Customise the streaming-phase card JSON |
build_final_rich_card(...) |
function | Customise the final swap-in card |
StreamingCardError |
exception | Raised when card creation / message send fails |
Tuning (StreamingCardConfig)
| Field | Default | Effect |
|---|---|---|
element_id |
"answer" |
id of the markdown block updated mid-stream |
title |
"AI" |
header title shown above streaming text |
final_header_template |
"blue" |
colour template for the rich final card |
streaming_summary |
"[生成中...]" |
chat-preview text while streaming |
print_step |
1 |
client animates this many chars per frame |
print_frequency_ms |
50 |
ms between client-side animation frames |
print_strategy |
"delay" |
"delay" queues every char (no skips); "fast" flushes the queue on each push |
min_push_interval_s |
0.12 |
server-side throttle (CardKit caps at 10 pushes/s/card) |
final_max_body_chars |
28000 |
cap for the markdown body of the swap-in card |
A typical LLM (~80 chars/s) feels best with the defaults. If your LLM bursts faster, raise print_step to 2–3 and lower print_frequency_ms to 30 so the client keeps up.
Custom card layouts
Pass initial_card= (streaming phase) or final_card= (swap-in) to use your own v2 card JSON:
async with await StreamingCard.open(
client=client, chat_id=open_id,
initial_card={
"schema": "2.0",
"header": {"title": {"tag": "plain_text", "content": "Custom"}},
"config": {
"streaming_mode": True,
"summary": {"content": "thinking…"},
},
"body": {"elements": [
{"tag": "markdown", "element_id": "answer", "content": ""},
]},
},
) as card:
...
await card.close(final_card={
"schema": "2.0",
"header": {"title": {"tag": "plain_text", "content": "Done"},
"template": "purple"},
"config": {"streaming_mode": False, "enable_forward": True},
"body": {"elements": [
{"tag": "markdown", "content": final_text},
{"tag": "action", "actions": [
{"tag": "button", "text": {"tag": "plain_text", "content": "👍"},
"value": {"vote": "up"}},
{"tag": "button", "text": {"tag": "plain_text", "content": "👎"},
"value": {"vote": "down"}},
]},
]},
})
The streaming phase only allows a single markdown element — that's a Feishu limit, not ours. Buttons / panels / multiple sections must wait for the swap-in.
Limits & gotchas
- Single markdown element during streaming. CardKit rejects collapsible panels, action blocks, and additional elements while
streaming_mode: true. Usefinal_card=...to add them after. message_id↔card_idare different things.message_idis the IM message;card_idis the CardKit entity. Usemessage_idto thread replies,card_idif you need to call CardKit APIs directly.- A card entity can only be sent once. To deliver into another chat, open a new
StreamingCard. - Forwarding is disabled while streaming.
close()callscardkit.card.updatewhich clearsstreaming_mode; only then does the user see a Forward button. push(text)takes cumulative text, not deltas. Same-prefix pushes drive the typewriter; different-prefix pushes re-render the whole markdown.- CardKit auto-disables streaming after 10 minutes. Always call
close()(or use the context manager) anyway — leftover[生成中...]previews degrade UX.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
StreamingCardError: ... code=99991663 |
App missing cardkit:card:write scope |
Add scope, re-publish app version |
code=230003 schema error |
You passed v1 card JSON | All cards must have "schema": "2.0" |
Card stays stuck on [生成中...] |
close() never ran |
Use the async with form, or wrap your loop in try/finally |
| Typewriter "bursts then pauses" | LLM faster than client render | Raise print_step to 2–3 and/or lower print_frequency_ms |
code=230020 sequence error |
Concurrent producers without serialisation | Library serialises automatically; only happens if you call internals directly |
License
MIT — see LICENSE.
Author
Kai Sleung <kasileung96@gmail.com>
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file feishu_streaming_card-0.1.0.tar.gz.
File metadata
- Download URL: feishu_streaming_card-0.1.0.tar.gz
- Upload date:
- Size: 14.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2e682150a772ef93dbfa6413e637f784e023e68630c83f0716fbe9434c6a0dc0
|
|
| MD5 |
dab3cb166ef5cb64e26904fef96137c3
|
|
| BLAKE2b-256 |
f9dbf89963e9996873717aedcbf936650bc5b02c7411096579eb347a880bcc1e
|
File details
Details for the file feishu_streaming_card-0.1.0-py3-none-any.whl.
File metadata
- Download URL: feishu_streaming_card-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
728ecfc955566e17d369050056d0c0eca6e498a58f2eaa2bca79ba2c74a1b134
|
|
| MD5 |
4fd38f2e833c715bde276f6790aa8d84
|
|
| BLAKE2b-256 |
676652d1d192f33eac035578d3fdb458654552869c6926042deaad615af8280e
|