Streaming Cards

Every conversation turn produces a live-updating Lark card, your primary window for perceiving and controlling the CLI on your phone or in Lark.

Streaming card

  • Live screenshots of the terminal refreshed onto the card: xterm renders headlessly into an image that faithfully reproduces the CLI's TUI (borders and colors are all there), instead of converting output to Markdown. One click to "Show / Hide output," "Export text," and "Half-page up / down."
  • Live status indicator: the card's header color is the status (a Lark card template, not an emoji dot in the body) — Starting… (yellow) → Working (blue) → Waiting for input (green); when the quota is used up it shows Limit reached (red), turning to Retryable (green) when it can retry.
  • Operate directly from the card: open the Web terminal, 🔑 grab an operation link, close the session, and — when the quota is retryable — "🔁 Resend last task."
  • A fresh card per turn: the previous card freezes as an archive, keeping conversation history clear and traceable; after a session is moved to another group with /relay, the original card also automatically freezes as an archive (buttons removed).
  • A "recoverable" card on close: it carries a "▶️ Resume session" button to click back in anytime; if the CLI supports native resume (the adapter implements buildResumeCommand and a native session id exists), it also includes the native command (e.g. claude --resume <id>) for manual recovery; when unsupported, only botmux's resume button plus a short note is shown.

The four display layers of a turn

botmux's "presence" in Lark has four independently switched layers:

LayerWhat it isHow to turn it off
Streaming status cardThe per-turn live-screenshot card (the main subject of this page)Bot-level disableStreamingCard, or /card off for this chat
Thinking bubble (CoT)Intermediate narration / thinking messages while the CLI worksBot-level cotEnabled: false, or /cot off for this chat; /cot show reveals one round on demand
✋ → ✅ reactionsProgress reactions on your triggering message: ✋ when accepted, flipped to ✅ when the worker goes idleBot-level silentTurnReactions: true
Proactive CLI messagesRich-text / image messages the agent sends via botmux sendNo switch — sent on demand (see below)

Reactions and streaming cards are mutually exclusive per turn: the ✋→✅ reactions appear only when "streaming cards are off for this chat" — with no live status window, the reaction is the only lightweight progress signal; a card-enabled chat gets no reactions.

Card / thinking switches: bot-level vs per-chat

Display layerBot-level default (Bot config / Dashboard Bot Defaults)Per-chat override (in-group slash command)Default
Streaming status carddisableStreamingCard/card off / /card on (writes noCardChats); bare /card summons one immediately; /card pin … controls pinningOn
Thinking bubblecotEnabled/cot off / /cot on (writes noCotChats); /cot show reveals one round; bare /cot shows statusOn
✋ → ✅ reactionssilentTurnReactionsNo per-chat commandCard-off chats get reactions automatically; silentTurnReactions: true silences even those
Card controlshiddenStreamingCardButtons (choose which buttons to hide)—All shown
Card pinningpinStreamingCard (default off)/card pin off|on|statusOff

A per-chat override wins over the bot-level default and affects only that chat.

/card and /cot are admin-only (allowedUsers, canOperate), status subcommands included. The reason: Lark cards have no "show different views per person" capability — these switches are bot-level / chat-wide, so one change alters what everyone in the group sees; guests (talk access, no operate rights) get an "authorized users only" notice.

Quiet preset (Dashboard Bot Defaults page, new in this batch): one click performs a one-time linked write — cotEnabled:false + silentTurnReactions:true + disableStreamingCard:true — for groups that only want the conclusion, quietly. It is not a persistent binding: it simply writes those three existing switches to the values above once and shows a toast; turning the preset off later does not revert the three values, so re-enable each switch manually if you want it back.

Pinning The Current Live Card

When a bot enables pinStreamingCard, Botmux tries to pin the current public live-status card to the top of the chat so the close-session and terminal entry points stay easy to reach.

  • This is a per-bot, opt-in, default-off setting.
  • Pinning is still chat-wide at the Feishu layer, while each active session keeps its own current/frozen streaming-card lifecycle. If the same chat has multiple active topics or multiple bots, you may therefore see multiple independently managed group-level Pin entries.
  • Only the current public live-status real streamCardId participates.
  • Repo-picker cards, private /card snapshots, final reply cards, CoT, closed cards, and every other interactive card stay out of scope.
  • After the switch changes through the dashboard or /botconfig set pinStreamingCard on/off, Botmux immediately runs a best-effort hot reconciliation across that bot's existing active sessions; the configuration response itself does not wait for Feishu Pin/Unpin completion.
  • /card pin off is the per-chat escape hatch: it stops Botmux from pinning streaming cards in the current chat while keeping live cards themselves enabled. /card pin on restores Pin for that chat, and /card pin status reports whether Pin is off at the bot level, opted out for this chat, or effectively on.
  • Failures are fail-open: they never interrupt card publication, transfer, resume, close, or the configuration write. During exceptional periods you may temporarily see zero Pins or multiple Pins.
  • The feature keeps no durable retry journal, and startup recovery remains deliberately narrow. On restart, Botmux lists the chat's current Pins as the single ownership authority for persisted cards: a remote Pin counts only when Feishu reports operator_id_type: "app_id" for the same larkAppId, and cleanup is further restricted to the strict intersection with the enqueue-time local candidate IDs already known to this process. An already-Pinned current card with human, other-app, mixed, or malformed provenance is left untouched and is not re-pinned. If the current card is absent, Botmux accepts a create only when the returned data.pin repeats both the exact message ID and same-app provenance. Botmux never broad-cleans arbitrary remote Pins, and any lookup or Pin API failure stays fail-open. Explicit bot-wide/per-chat off cleans process-owned IDs plus locally known IDs freshly proven same-app; ordinary disable, close, and transfer clean process-owned IDs only.

Open terminal = read-only: the card's main "🖥️ Open Web Terminal" button is read-only viewing; for writable control, tap "🔑 Get operation link" — delivered privately: a flat group prefers an in-chat "visible-to-you" ephemeral card (so you never leave the conversation), falling back to a DM only for topic/thread or p2p chats, or when the ephemeral card fails. Management buttons like "🔄 Restart" and "apply profile" live on the session card, not on each turn's streaming card.

Interrupting / correcting a running turn

To stop or correct it mid-turn, don't wait for it to finish: in screenshot mode the card carries a row of quick keys at the bottom — Esc, ^C, Tab, Space, Enter, arrow keys, ⇞ Half-page up / ⇟ Half-page down. Tapping Esc writes the ESC byte straight into the live terminal (exactly like pressing Esc locally); ^C likewise. After interrupting, just add a new instruction.

This quick-key row only appears when output is shown (screenshot mode) and the backend isn't riff — "Show output" first, then Esc is available. The default behavior is not to interrupt the current turn; new messages queue (type-ahead) and are fed in after the turn ends. To correct immediately, use Esc to break first.

Messages the CLI proactively sends

The card body is a live screenshot (image) of the terminal, not text rendering. Messages the CLI proactively sends (via botmux send) are separate rich-text / image-and-text messages that can carry images, files, and @mentions; for fully custom display, --card-file / --card-json can send raw interactive card JSON.

⚠️ Raw cards allow display-only elements + open_url buttons only: any callback-firing control — callback buttons (with a value), dropdown / person selects, date-time pickers, inputs, form submits — is rejected. This prevents custom cards from forging interactive callbacks.

Charts in messages (vega-lite)

A ```vega-lite fence in a botmux send body renders as a native Feishu Card 2.0 chart; other channels such as a Web view can render the same Vega-Lite directly. Supported subset:

  • Inline data.values only, at most 500 rows, values are string / number / boolean / null.
  • mark: bar (horizontal when only x is quantitative), line, area, point, arc (pie; innerRadius > 0 for a donut). A pie takes only theta (value) + color (category); x/y are rejected and theta has no title.
  • Encoding channels: x, y, color (series), theta (pie value); each channel accepts only field, type, title. x/y titles become axis titles and the color title becomes the legend title. type: temporal is not parsed as a date: values are shown as categories in the given order, so sort them first.
  • At most 5 charts per card. The real limit is Feishu's 30KB card message request body (including the envelope and the re-serialized content): once the card is assembled, charts that do not fit step down chart → 50-row data table → 10-row data table → notice only until the card fits. A single spec source is also capped at 30KB for parsing.

Safety: the host rebuilds the VChart spec from recognised fields and never forwards the source JSON. data.url, transform, params, expr, signal, datasets, layer and similar are rejected. Unsupported or over-budget charts degrade to a one-line notice plus a plain-text data table (cells never become links or mentions), botmux send prints the reason on stderr, and the message is still delivered.

botmux send --dry-run previews the rendering: it prints {dryRun, bytes, fits, diagnostics, card} (bytes is the Feishu request body size) without sending and without a session. It renders the body only (no image/file upload, mentions or footer).

Updating a card after sending (card patch)

A successful botmux send --card-file/--card-json prints {"success":true,"messageId":"om_...",...}. botmux card patch updates that same card in place by its messageId — no new message, same chat/topic — which makes it ideal for progress cards:

# 1. Send an "in progress" card and grab its messageId from the JSON output
botmux send --card-json '{"schema":"2.0","header":{"template":"blue","title":{"tag":"plain_text","content":"Deploy progress"}},"body":{"direction":"vertical","elements":[{"tag":"markdown","content":"Progress: 0%"}]}}' --no-mention
# → {"success":true,"messageId":"om_xxx","sessionId":"..."}

# 2. Extract the messageId with jq, then patch it to 50%
MID=$(botmux send --card-file /tmp/progress.json --no-mention | jq -r .messageId)
botmux card patch --message-id "$MID" --card-json '{"schema":"2.0","header":{"template":"blue","title":{"tag":"plain_text","content":"Deploy progress"}},"body":{"direction":"vertical","elements":[{"tag":"markdown","content":"Progress: 50%"}]}}'

# 3. Patch once more when done
botmux card patch --message-id "$MID" --card-json '{"schema":"2.0","header":{"template":"green","title":{"tag":"plain_text","content":"Deploy done"}},"body":{"direction":"vertical","elements":[{"tag":"markdown","content":"✅ Shipped"}]}}'
  • The replacement card JSON goes through the same safety validation as sending (display-only + open_url; callback controls are rejected).
  • The send examples pass --no-mention: a progress card doesn't need to @ anyone, and explicitly opting out avoids the mention-policy gate (exit 2).
  • Bot identity is resolved from the session context (same as send); errors such as a withdrawn message, missing permission, or a non-card target are surfaced as-is (exit 1).
  • Success prints {"success":true,"messageId":"om_xxx","sessionId":"..."} (JSON only on stdout); usage errors exit 2.