interactive displays
bots can send styled, interactive interfaces inside public or private chat messages. layouts and state update independently, including while users interact with them.
send a display
POST /chats/{chat}/messages with actor:bot, a required plain-text content fallback and display. send the same display field to POST /chats/{chat}/ephemeral-messages or POST /interactions/{id}/responses. the receipt has has_display:true. the display is stored with its message, and deleting or expiring the message removes its display and retained image references. content remains the searchable, notification and older-client summary; display updates do not create messages, unread activity or repeated notifications.
{
"actor": "bot",
"content": "music player · night drive",
"display": {
"version": 1,
"state": {
"title": "night drive",
"artist": "example artist",
"track": "track-1",
"playback": {
"position": 30,
"duration": 240,
"at": 1792526400000,
"rate": 1
},
"queue": [
"next song",
"another song"
]
},
"styles": {
"heading": {
"fontSize": 20,
"fontWeight": "bold"
},
"secondary": {
"fontSize": 14,
"color": "muted"
}
},
"actions": {
"play": {
"type": "bot",
"audience": "chat_members",
"context": {
"bind": "track"
}
},
"pause": {
"type": "bot",
"audience": "chat_members",
"context": {
"bind": "track"
}
},
"seek": {
"type": "bot",
"audience": "chat_members",
"context": {
"bind": "track"
}
}
},
"root": {
"id": "player",
"type": "column",
"children": [
{
"id": "header",
"type": "row",
"children": [
{
"id": "cover-placeholder",
"type": "icon",
"props": {
"name": "music",
"label": "music"
}
},
{
"id": "metadata",
"type": "column",
"style": {
"grow": 1,
"gap": 4
},
"children": [
{
"id": "title",
"type": "text",
"style_ref": "heading",
"props": {
"text": {
"bind": "title"
}
}
},
{
"id": "artist",
"type": "text",
"style_ref": "secondary",
"props": {
"text": {
"bind": "artist"
}
}
}
]
}
]
},
{
"id": "seek",
"type": "slider",
"props": {
"label": "playback position",
"timeline": {
"bind": "playback"
},
"action": "seek",
"step": 1
}
},
{
"id": "controls",
"type": "row",
"children": [
{
"id": "play",
"type": "button",
"props": {
"label": "play",
"icon": "play",
"action": "play"
}
},
{
"id": "pause",
"type": "button",
"props": {
"label": "pause",
"icon": "pause",
"action": "pause"
}
}
]
},
{
"id": "queue",
"type": "details",
"props": {
"label": "up next"
},
"children": [
{
"id": "queue-list",
"type": "list",
"props": {
"items": {
"bind": "queue"
}
}
}
]
}
]
}
}
}document and state
a version:1 document contains root, state and actions, with optional reusable styles. every node has a unique stable id and type, plus optional props, children, style, narrow_style and style_ref. identifiers contain 1–64 ASCII letters, digits, underscores or hyphens; reserved prototype keys are invalid. state is a flat map of strings, finite numbers, booleans, null, text lists, text tables or timelines. a property written as {bind:key} reads one state entry. bindings must exist and resolve to the property's expected type. bot actions may bind a scalar context such as the current track ID; the server rejects clicks against obsolete contexts. state is visible to everyone who can see the message, so keep credentials and private backend data on your server.
available components
layout components can nest up to eight levels. labels are required for controls, tabs, details, icons and progress indicators. strings are rendered as text; markdown uses the existing safe message renderer. command and link actions are supported on buttons. forms submit only when the user explicitly submits them.
scroll the table horizontally to see all columns.
| type | properties and behavior |
|---|---|
| row, column, grid | children; flex rows wrap by default; grids support up to six columns |
| text, markdown, badge | text; safe formatted text uses markdown |
| image | asset_id, alt, optional fit:cover|contain; an empty alt marks decorative artwork |
| icon, divider | icon requires name and label; divider has no properties |
| button | label, action, optional icon and disabled |
| select | label, options:text[], action, optional value and disabled |
| toggle | label, value:boolean, action, optional disabled |
| slider | label, action, value/min/max/step or timeline, optional disabled; sends the committed value after dragging or keyboard adjustment |
| input | label, action, optional placeholder and disabled; explicit submit sends up to 4,000 characters |
| progress, timeline | label and value/max or timeline; timeline requires a timeline object |
| list, table | list uses items:text[]; table uses headers:text[] and rows:text[][] with matching widths |
| details | label and children; expansion is local and survives state updates |
| tabs, tab | tabs requires label and 1–12 tab children; each tab requires label and may contain components; keyboard arrows, Home and End select tabs |
layout and styling
style_ref selects a reusable entry in styles; inline style overrides it. narrow_style overrides both when the display is narrower than 420 pixels. allowed properties are gap/padding/radius (0–48px), fontSize (12–48px), borderWidth (0–4px), width/maxWidth (24–960px or fill), height (24–480px), grow (0–12), columns (up to six values such as 1fr 2fr or auto), align (start, center, end, stretch), justify (start, center, end, space-between, space-around), wrap (wrap, nowrap), textAlign (left, center, right) and fontWeight (normal, medium, bold). color, background, borderColor and gradientTo accept #rrggbb, #rrggbbaa or theme tokens surface, ink, muted, accent, line and transparent. gradientTo creates a diagonal gradient from background. use theme tokens for readable themes and maintain contrast with custom colors. displays stay within the chat width and preserve platform focus indicators. arbitrary CSS, HTML, JavaScript, remote fonts, iframes, browser storage and direct network fetches are not supported. available icon names are play, pause, skip, previous, plus, music, volume, refresh, check, x, heart and link.
album covers and images
upload images through the existing bot upload flow using actor:bot, purpose:attachment, bot.attachments.send and the same installation and chat. after completion, set the returned asset ID on the image node, directly or through state. PNG, JPEG, WebP, GIF and AVIF are supported, up to 40 million decoded pixels within the normal upload limits. SVG and external image URLs are rejected. retained display images cannot be deleted through the asset API while referenced. display assets are reserved for displays; upload a separate copy to send the image as a normal attachment. image bytes use authenticated audience checks, including private-message expiration and current membership; users' browsers do not fetch arbitrary bot-supplied image URLs.
partial and realtime updates
GET the display snapshot, then PATCH with If-Match quoting its revision and an Idempotency-Key. send either document for full replacement or operations for atomic patches. the server validates the complete result before committing anything. a conflict returns 412; refetch and recompute the change. snapshots contain document, revision, layout_revision, updated_at, server_time, active and installer. revision changes after every write; layout_revision changes only for structural/property changes or replacement. state patches preserve component IDs and local input, expansion, selected tab and focus. update the textual message separately when its searchable summary changes.
scroll the table horizontally to see all columns.
| method and route | result |
|---|---|
| GET /chats/{chat}/messages/{id}/display | public display snapshot; no ambient message-read grant required for the installation's own display |
| PATCH /chats/{chat}/messages/{id}/display | atomic public display update |
| GET /chats/{chat}/ephemeral-messages/{id}/display | private display snapshot for the owning installation |
| PATCH /chats/{chat}/ephemeral-messages/{id}/display | atomic private display update |
{
"operations": [
{
"op": "state",
"values": {
"title": "next track",
"track": "track-2",
"playback": {
"position": 0,
"duration": 212,
"at": 1792526400000,
"rate": 1
}
}
},
{
"op": "props",
"id": "play",
"values": {
"disabled": true
}
}
]
}patch operations
state merges values into the state map. props merges values into one node's properties. replace replaces the named node with node; remove deletes a non-root node; insert inserts node into the named parent's children at zero-based index. operations run in order and roll back together on failure. use full document replacement to remove state keys, style definitions, actions or properties. keep bindings valid throughout structural edits and preserve IDs when retaining a control.
{
"operations": [
{
"op": "state",
"values": {
"queue": [
"another song"
]
}
},
{
"op": "insert",
"id": "player",
"index": 0,
"node": {
"id": "notice",
"type": "badge",
"props": {
"text": "live"
}
}
},
{
"op": "replace",
"id": "notice",
"node": {
"id": "notice",
"type": "text",
"props": {
"text": "updated"
}
}
},
{
"op": "remove",
"id": "notice"
}
]
}realtime clocks and external data
a timeline is {position,duration,at,rate}. position and duration are seconds; at is a Unix timestamp in milliseconds; rate is 0–4, with zero meaning paused. the client interpolates and clamps the position locally from the server clock. update on play, pause, seek, track change or occasional drift correction, not every animation frame. bots consume their own external API, webhook or stream and submit state patches to Sometimes. the browser never runs bot code. updates are coalesced and sent to current viewers; missed or out-of-order revisions trigger a snapshot fetch. reconnect, visibility return and periodic reconciliation refresh access and state. hidden/offscreen displays stop their timers and refresh when visible.
clicks and bot responses
actions map IDs to {type:bot,audience:installer|chat_members,context?}. a button or input refers to its action ID in props.action. chat_members permits current eligible human members to interact with this bot-owned display; installer restricts the control to the installing user. private bots still require the clicking member to be the owner or invited. the existing interaction.created gateway event has kind:component, actor_id for the actual clicker, installer_id for the installation owner, message_id, component_id, action_id, layout_revision, context and options:{value}. command_id is null. acknowledge within three seconds through POST /interactions/{id}/ack, then update the display or send ordinary interaction responses within 15 minutes. public ACKs/replies require bot.messages.send; private ones require bot.messages.ephemeral. commands is not required for component actions. an ephemeral response goes only to the clicker; its audience is interaction when that person differs from the installer. component clicks never grant access to that person's account, chat history or delegated user actions. bots must authorize the operation represented by each action and treat actor_id as the requesting identity. replay and responses recheck current eligibility, blocks, installation access and grants.
limits, lifecycle and fallback
documents are at most 32 KiB of UTF-8 JSON with 128 nodes, depth 8, 32 actions and 32 patch operations per request. text values are at most 4,000 characters; labels/alt/placeholder are at most 200; lists/tables have at most 100 rows, 12 table columns and 500 characters per cell. updates are limited to five per second per installation/chat, in addition to normal REST limits. member interactions allow ten per five seconds per member/application. unknown fields, invalid types, unsupported versions and unresolved bindings reject the whole request. paused, revoked, excluded, disabled or inaccessible displays lose live controls. public historical layouts remain readable to eligible chat members; private displays disappear when access ends or they expire. fallback content is shown until a valid layout loads. controls expose keyboard labels, loading/errors and retry without losing draft input. data bound into a display is treated as message content by the normal text filter.
Sometimes client protocol
these routes use a verified human session, not a bot credential. every request enforces membership and the display audience. POST interactions accepts a unique id, component_id, action_id, layout_revision, context and value; reusing an ID with changed input fails. bot_display_changed on the existing human chat socket contains conversation_id/id and either an invalidation or from_revision, revision, layout_revision, operations, updated_at and server_time. clients apply only contiguous revisions and refetch otherwise; these frames never include hidden private state.
scroll the table horizontally to see all columns.
| method and route | result |
|---|---|
| GET /api/conversations/{chat}/bot-displays/{id} | authorized snapshot |
| GET /api/conversations/{chat}/bot-displays/{id}/assets/{asset} | authorized image bytes |
| POST /api/conversations/{chat}/bot-displays/{id}/interactions | interaction receipt with id, ack_deadline and expires_at |
run the music player example
download music-player.mjs and music-display.json into one directory and run with Node.js 24+. configure commands, bot.messages.components, bot.messages.send, bot.messages.edit and bot.messages.ephemeral, register a queue command with one required string option named song, then publish and install. set SOMETIMES_BOT_TOKEN, SOMETIMES_INSTALLATION, SOMETIMES_CHAT and SOMETIMES_QUEUE_COMMAND. for artwork, also authorize bot.attachments.send, then optionally set SOMETIMES_COVER_ASSET to a completed image upload for that chat. run node music-player.mjs. the example creates one player, handles play/pause/seek, queues songs through the command button, persists its message/cursor and reconnects. it models playback state; connect its action handler to your audio service or the existing bot call audio API to actually play music. display permission alone does not stream audio or bypass audio consent.