Frames
Reference article: Part of the API reference. Frames are posted by Apps, through an incoming webhook or MCP.
A Frame is a structured message an App posts into a room: a card with Markdown sections, label and value fields, tables, buttons, and menus, drawn by Nimbus in its own style. People can press a Frame's buttons and pick from its menus, and Nimbus sends each interaction to your App, which can reply by changing the card in place.
This page describes Nimbus Frames v1: a closed set of five node types. You describe the layout as JSON; you don't send HTML or styles.
{
"nodes": [
{ "type": "section", "text": "**Nightly build passed** on `main`." },
{ "type": "context", "text": "Build 8812 · 6m 40s" }
]
}
Frame rules {#frame-rules}
| Rule | Detail |
|---|---|
| Nodes | A Frame has 1–25 nodes, drawn top to bottom. |
| Version | Optional "version": 1. It's the only version, and the default. |
| Fallback text | Optional fallback_text, 1–3,000 characters. See Fallback text. |
| Closed set | Only the five node types below, with only the fields listed. An unknown node type or field is rejected with 400 frame_invalid, never silently dropped. The message names the problem, and param gives its path, such as frame.nodes.0.type. |
| Unique action IDs | Every action_id on a button (other than link buttons) or select must be unique within the Frame. |
| Safe Markdown | Markdown fields are rendered safely: raw HTML is shown as text, only https and mailto links work, and images aren't shown. |
Because the set is closed, your App finds out about a mistake when it posts, not when someone sees a half-drawn card.
Fallback text {#fallback-text}
Nimbus uses a Frame's plain text for notifications and search. If you set fallback_text, that's used. Otherwise Nimbus generates a plain-text rendering from the nodes: section text and fields, tables as Markdown tables, buttons as [Label], and so on. If you post a message with both text and frame, the message's text is what you sent.
Nodes
section
Markdown text, optionally with label and value fields shown in two columns. A section needs text, fields, or both.
| Field | Type | Description |
|---|---|---|
type |
string | section |
text |
string, optional | Markdown, 1–3,000 characters. |
fields |
array, optional | Up to 10 { "label", "value" } pairs. label is plain text, 1–60 characters; value is Markdown, 1–300 characters. |
{
"type": "section",
"text": "**Invoice INV-2291** is 14 days overdue.",
"fields": [
{ "label": "Customer", "value": "Northwind Traders" },
{ "label": "Amount", "value": "$18,400.00" }
]
}
actions
A row of 1–5 buttons.
| Field | Type | Description |
|---|---|---|
type |
string | actions |
elements |
array | 1–5 buttons. |
Each button takes these fields:
| Field | Type | Description |
|---|---|---|
type |
string | button |
action_id |
string | Identifies the button in the interaction. 1–64 letters, digits, and _ . : -. |
label |
string | The button's text, 1–40 characters. |
value |
string, optional | Sent to your App with the interaction, up to 2,000 characters. Use it for the ID of the thing the button acts on. |
style |
string, optional | default, primary, or danger. Default default. |
url |
string, optional | An https URL. The button opens this link instead of sending an interaction, and your App isn't called. |
confirm |
object, optional | Ask the person to confirm first. See below. |
The confirm object takes these fields:
| Field | Type | Description |
|---|---|---|
title |
string | The dialog's title, 1–100 characters. |
text |
string | Markdown, 1–300 characters: what will happen. |
confirm_label |
string, optional | 1–30 characters. Default Confirm. |
deny_label |
string, optional | 1–30 characters. Default Cancel. |
{
"type": "actions",
"elements": [
{ "type": "button", "action_id": "invoice.remind", "label": "Send reminder", "style": "primary", "value": "INV-2291" },
{ "type": "button", "action_id": "invoice.open", "label": "Open in billing", "url": "https://billing.example.com/invoices/INV-2291" }
]
}
select
A menu of options, for one choice or several.
| Field | Type | Description |
|---|---|---|
type |
string | select |
action_id |
string | Identifies the menu in the interaction. Same format as a button's. |
label |
string, optional | Shown with the menu, up to 60 characters. |
placeholder |
string, optional | Shown before anything is chosen, up to 60 characters. |
multiple |
boolean, optional | true to allow several choices. Default false. |
options |
array | 1–50 { "label", "value" } options. label is 1–75 characters, value 1–150. |
initial |
string[], optional | The values chosen to start with, up to 50. |
{
"type": "select",
"action_id": "invoice.assign",
"label": "Assign to",
"placeholder": "Choose an owner",
"options": [
{ "label": "Accounts receivable", "value": "team-ar" },
{ "label": "Customer success", "value": "team-cs" }
]
}
table
A table of text cells. People can open it as a spreadsheet, like any table in chat.
| Field | Type | Description |
|---|---|---|
type |
string | table |
columns |
string[] | 1–20 column headings, each 1–60 characters. |
rows |
array | Up to 100 rows. Each row is an array of cells, up to 500 characters each, and must have exactly as many cells as there are columns. |
{
"type": "table",
"columns": ["Invoice", "Customer", "Days overdue"],
"rows": [
["INV-2291", "Northwind Traders", "14"],
["INV-2304", "Contoso", "6"]
]
}
context
Muted small print, for provenance, timestamps, and footers.
| Field | Type | Description |
|---|---|---|
type |
string | context |
text |
string | Markdown, 1–500 characters. |
{ "type": "context", "text": "From the billing system · updated 09:14 UTC" }
A complete example
A deploy bot posts this Frame when a production deploy finishes. It has a section with fields, a table of services, a Roll back button that asks for confirmation, a View logs link button, and a footer:
{
"fallback_text": "Deploy v2.14 to production succeeded: api, worker and web are healthy.",
"nodes": [
{
"type": "section",
"text": "**Deploy `v2.14` to production** finished.",
"fields": [
{ "label": "Environment", "value": "production" },
{ "label": "Duration", "value": "4m 12s" },
{ "label": "Triggered by", "value": "Priya Shah" },
{ "label": "Commit", "value": "[8f3c2d1](https://github.com/example/app/commit/8f3c2d1)" }
]
},
{
"type": "table",
"columns": ["Service", "Version", "Status"],
"rows": [
["api", "v2.14", "Healthy"],
["worker", "v2.14", "Healthy"],
["web", "v2.14", "Healthy"]
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"action_id": "deploy.rollback",
"label": "Roll back",
"style": "danger",
"value": "v2.13",
"confirm": {
"title": "Roll back production?",
"text": "This redeploys **v2.13** to all three services.",
"confirm_label": "Roll back",
"deny_label": "Keep v2.14"
}
},
{
"type": "button",
"action_id": "deploy.logs",
"label": "View logs",
"url": "https://ci.example.com/deploys/8812"
}
]
},
{ "type": "context", "text": "Deploy #8812 · pipeline `release.yml`" }
]
}
Post it with the install's incoming webhook, wrapped as { "frame": … }:
::: code-group
# deploy-frame.json holds the Frame above
BODY=$(jq -c '{frame: .}' deploy-frame.json)
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl "$HOOK_URL" \
-H "Content-Type: application/json" \
-H "Nimbus-Signature: t=$TS,v1=$SIG" \
--data-raw "$BODY"
import { createHmac } from 'node:crypto'
import deployFrame from './deploy-frame.json' with { type: 'json' }
const body = JSON.stringify({ frame: deployFrame })
const t = Math.floor(Date.now() / 1000)
const signature = createHmac('sha256', process.env.NIMBUS_SIGNING_SECRET!).update(`${t}.${body}`).digest('hex')
const response = await fetch(process.env.NIMBUS_HOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Nimbus-Signature': `t=${t},v1=${signature}` },
body,
})
const message = await response.json() // message.frame holds the validated Frame
import hashlib
import hmac
import json
import os
import time
import requests
with open("deploy-frame.json") as f:
body = json.dumps({"frame": json.load(f)}).encode("utf-8")
t = str(int(time.time()))
signature = hmac.new(os.environ["NIMBUS_SIGNING_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
response = requests.post(
os.environ["NIMBUS_HOOK_URL"],
headers={"Content-Type": "application/json", "Nimbus-Signature": f"t={t},v1={signature}"},
data=body,
)
message = response.json() # message["frame"] holds the validated Frame
:::
Interactions {#interactions}
When a person presses a button, after confirming if the button has a confirm, or picks from a select, Nimbus sends a signed POST to the App's interaction URL (its callback_url). Link buttons, with a url, just open the link and don't call your App.
The request
The request is signed like any webhook from Nimbus, with the install's signing secret, in the Nimbus-Signature header. Verify it before you act. It also carries Nimbus-Event-Id, set to the interaction's id.
{
"id": "act_6Rb1Vx8Lm3Qt5Nc9Kw2Hd7Pz",
"object": "frame_action",
"created": 1758793020,
"app": "app_5Hc2Lq9Rv4Tx7Nb1Mz8Kw3Pd",
"install": "ins_8Wd3Kp6Rm1Vt4Lx9Qb2Nh7Cz",
"surface": { "kind": "workstream", "id": "3f6c2a1e-8b4d-4e2f-9a7c-5d1b0e6f2a94", "workspace": "default" },
"message": {
"id": "2c7e9a41-5b3d-4f8e-a6c1-9d0b4e2f7a58",
"object": "message",
"author": { "type": "app", "id": "app_5Hc2Lq9Rv4Tx7Nb1Mz8Kw3Pd", "name": "Deploy Bot" },
"frame": { "version": 1, "nodes": ["…"] }
},
"action": { "action_id": "deploy.rollback", "value": "v2.13" },
"user": { "id": "d4e8b1a6-2c7f-4e9b-8a3d-5f0c1b7e2a94", "name": "Priya Shah" }
}
The message is shortened here; it's the full Message object with its frame.
| Field | Type | Description |
|---|---|---|
id |
string | The interaction's ID (act_…). |
object |
string | Always frame_action. |
created |
integer | When the person acted, in Unix seconds. |
app |
string | Your App's ID. |
install |
string | The install the message was posted through. |
surface |
object | The room: kind, id, and workspace, as on the AppInstall object. |
message |
object | The message the control is on, including its frame. |
action.action_id |
string | The action_id of the button or select. |
action.value |
string, optional | A single value, such as the button's value. |
action.values |
string[], optional | The chosen values, for a select. |
user.id |
string | The person who acted. |
user.name |
string or null | Their display name. |
Responding
Answer with a 2xx status within 10 seconds. Otherwise the person sees "App name didn't respond", and the interaction isn't retried. Redirects aren't followed.
To change the card for everyone, reply with JSON containing an update:
{
"update": {
"text": "Rolling back to v2.13…",
"frame": {
"nodes": [
{ "type": "section", "text": "**Rolling back production to `v2.13`**, started by Priya Shah." },
{ "type": "context", "text": "Deploy #8812 · rollback #8813" }
]
}
}
}
The update replaces the message's content in place. Send text, frame, or both; a field you leave out keeps its current value. An update that isn't valid, such as a Frame that breaks the rules, is ignored, and the message stays as it was. An empty 2xx response changes nothing.
For work that takes longer than 10 seconds, reply straight away with an update such as "Rolling back…", do the work, and then change the message again through MCP's messages_update tool.
Handling interactions
::: code-group
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'
const SECRET = process.env.NIMBUS_SIGNING_SECRET! // the install's whsec_… secret
function verify(rawBody: Buffer, header: string | undefined): boolean {
if (!header) return false
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=') as [string, string]))
const t = Number(parts.t)
if (!Number.isInteger(t) || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest()
const given = Buffer.from(parts.v1, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
}
const app = express()
app.post('/nimbus/interactions', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body, req.header('Nimbus-Signature'))) return res.status(400).send('Invalid signature')
const interaction = JSON.parse(req.body.toString('utf8'))
if (interaction.action.action_id === 'deploy.rollback') {
const version = interaction.action.value
await startRollback(version) // queue it; don't wait for it to finish
return res.json({
update: {
frame: {
nodes: [
{ type: 'section', text: `**Rolling back production to \`${version}\`**, started by ${interaction.user.name ?? 'someone'}.` },
{ type: 'context', text: `Requested ${new Date().toISOString()}` },
],
},
},
})
}
res.sendStatus(200) // acknowledge anything else without changing the card
})
app.listen(3000)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, jsonify, request
SECRET = os.environ["NIMBUS_SIGNING_SECRET"] # the install's whsec_… secret
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/nimbus/interactions")
def interactions():
raw_body = request.get_data()
if not verify(raw_body, request.headers.get("Nimbus-Signature")):
abort(400)
interaction = json.loads(raw_body)
if interaction["action"]["action_id"] == "deploy.rollback":
version = interaction["action"]["value"]
start_rollback(version) # queue it; don't wait for it to finish
name = interaction["user"]["name"] or "someone"
return jsonify(
update={
"frame": {
"nodes": [
{"type": "section", "text": f"**Rolling back production to `{version}`**, started by {name}."},
{"type": "context", "text": f"Requested {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}"},
]
}
}
)
return "", 200 # acknowledge anything else without changing the card
:::
When an interaction fails
These show in the app, to the person who pressed the control; they aren't API errors your code receives.
| Code | Why |
|---|---|
unknown_action |
The control isn't on that App message, or a select sent values that aren't among its options. Only controls actually on the message are relayed. |
app_has_no_callback |
The App has no interaction URL. Its buttons and selects still render, but pressing them fails. Set a callback_url on the App to take interactions. |
app_uninstalled |
The App has since been uninstalled from the room. |
app_callback_failed |
Your interaction URL didn't answer with a 2xx within 10 seconds. The person sees "App name didn't respond". |
FAQ
Can I use my own HTML or CSS in a Frame?
No. Frames are a closed set of nodes that Nimbus draws in its own style, so every App's cards look native and safe. Raw HTML in Markdown fields is shown as text.
Can an App change a message another App or a person posted?
No. An App can only update messages it posted itself, through interaction replies or MCP's messages_update.
Why did my post fail with frame_invalid?
Something in the Frame isn't in the v1 set, or breaks a limit. The error's param points at it, such as frame.nodes.2.elements.0.label, and the message says what's wrong. A common cause is a table row with a different number of cells from columns.
Do I need an interaction URL to post Frames?
No. Without one, Frames still render, including sections, tables, and link buttons. Only buttons and selects that send interactions need an interaction URL.
Does the agent see what's in a Frame?
Yes, as text. Nimbus keeps a plain-text rendering of every Frame, or your fallback_text, as the message's text, and that's what the agent, notifications, and search read. Set fallback_text if the generated rendering doesn't read well.