Frames

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.