# App Actions API

This is the wire reference for [App Actions](/docs/guides/app-actions): what
Roam sends to your Interactivity URL, and what you can send back.

For setting up an app, registering actions, and enabling the `commands` scope,
start with [App Actions](/docs/guides/app-actions).

## Delivery

Every app action interaction is a `POST` to the same **Interactivity URL**,
with `Content-Type: application/json`. The initial invocation and every later
modal event all go to that one URL. [Block Kit message-button clicks](/docs/guides/block-kit#interactivity)
use it too; they share `type` with modal `block_actions` (see [Routing](#routing)).

Requests are signed with your app's signing secret, using the
[Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks)
scheme. The signature is in the `webhook-id`, `webhook-timestamp`, and
`webhook-signature` headers. You should verify over the **raw** body (see
[Signature verification](/docs/webhooks/webhooks#signature-verification)).

:::caution
Slash command, message action, and modal payloads do **not** include a
`clientId` field. If one server backs several Roam apps, give each app its own
Interactivity URL (a distinct path is enough) — the request body alone will not
tell them apart.
:::

## Routing

Roam tells you what happened with a `type` field. `slash_command` and
`message_action` are the initial invocation. If you respond with a **modal**
(a Block Kit form), later events reuse the same URL:

- **`block_actions`** — an element **dispatched**: it committed a change and
  Roam is asking you about it before submit. Inputs do this only if you set
  [`dispatch_action`](#dispatch_action); buttons always dispatch.
- **`view_submission`** — the user submitted the form.

Both include a `viewId` you chose when you opened the modal, and `state` with
every input's current value. Neither includes the chat.

| `type` | Sent when | Routing key |
|---|---|---|
| `slash_command` | A user runs your command from the composer | `actionId` |
| `message_action` | A user picks your action from a message's menu | `actionId` |
| `block_actions` | An element in an open modal dispatched | `viewId` and `actions[0].actionId` |
| `view_submission` | A user submits an open modal | `viewId` |

:::caution
[Block Kit message-button clicks](/docs/guides/block-kit#interactivity) also
use `type: "block_actions"`. A modal interaction has a `viewId`; a
message-button click has a `clientId`.
:::

A typical handler first switches on `type`. Route initial invocations on the
top-level `actionId`, modal submissions on `viewId`, and modal element
interactions on `viewId` plus `actions[0].actionId` as needed.

```js
app.post("/roam/interactivity", async (req, res) => {
  const payload = verify(req);   // Standard Webhooks, over the raw body

  switch (payload.type) {
    case "slash_command":
    case "message_action":
      return handleAction(payload, res);
    case "block_actions":
      if (payload.viewId) {
        return handleModalAction(payload, res);
      }
      if (payload.clientId) {
        return handleMessageButton(payload, res);
      }
      return res.status(200).end();
    case "view_submission":
      return handleSubmit(payload, res);
    default:
      return res.status(200).end();
  }
});
```

## Common fields

| Field | Type | Description |
|---|---|---|
| `type` | string | One of the four interaction types above |
| `user` | object | The invoking user |
| `user.id` | string | The user's address ID |
| `user.email` | string | The user's verified email address |
| `user.accountId` | integer | The account (workspace) the action resolved in |

## `slash_command`

Sent when a user runs your command from the composer.

```json
{
  "type": "slash_command",
  "actionId": "create-issue",
  "user": {
    "id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
    "email": "alice@example.com",
    "accountId": 4821
  },
  "chat": {
    "id": "295155ae-7df5-4ed5-9ebc-89a170559c81"
  }
}
```

| Field | Type | Description |
|---|---|---|
| `actionId` | string | The action's ID, as registered |
| `chat.id` | string | The chat the command was run in |
| `chat.threadTimestamp` | integer | Unix **microseconds**. Present only when the command was run inside a thread. |

Slash commands carry no arguments. Text typed after the command name filters
the autocomplete menu and is not delivered.

## `message_action`

Sent when a user picks your action from a message's overflow menu. Identical to
`slash_command`, plus the message it was invoked on.

```json
{
  "type": "message_action",
  "actionId": "create-issue",
  "user": {
    "id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
    "email": "alice@example.com",
    "accountId": 4821
  },
  "chat": {
    "id": "295155ae-7df5-4ed5-9ebc-89a170559c81"
  },
  "message": {
    "chatId": "295155ae-7df5-4ed5-9ebc-89a170559c81",
    "timestamp": 1765602474760032,
    "userId": "7c2f1e40-5a8b-4c31-9d17-2b6e0f9a4c88",
    "contentType": "text",
    "text": "The deploy is failing on staging again"
  }
}
```

| Field | Type | Description |
|---|---|---|
| `message.chatId` | string | The chat containing the message |
| `message.timestamp` | integer | The message's timestamp, in Unix **microseconds**. This is the message's identity. |
| `message.threadTimestamp` | integer | The channel thread's parent timestamp. Omitted when the message is not in a channel thread. |
| `message.userId` | string | Address ID of the message's author |
| `message.contentType` | string | `"text"` or `"block"` |
| `message.text` | string | Markdown text. Present when `contentType` is `"text"`. |
| `message.blocks` | array | [Block Kit](/docs/guides/block-kit) blocks. Present when `contentType` is `"block"`. |
| `message.color` | string | The block message's color strip, when set |

To post into the same channel thread with [`chat.post`](/docs/api/chat-post), set
`threadTimestamp` to `message.threadTimestamp ?? message.timestamp`. To reply
directly to the selected message in a DM, set `replyTimestamp` to
`message.timestamp` instead. Direct replies support text messages only.

## `block_actions`

Sent when an element in an open modal dispatches (see
[`dispatch_action`](#dispatch_action) for which elements dispatch).

```json
{
  "type": "block_actions",
  "viewId": "create-issue",
  "user": {
    "id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
    "email": "alice@example.com",
    "accountId": 4821
  },
  "actions": [
    {
      "actionId": "project",
      "blockId": "project-block",
      "value": ""
    }
  ],
  "state": {
    "values": {
      "project-block": {
        "project": { "type": "static_select", "selected_option": { "value": "ROAM" } }
      },
      "summary-block": {
        "summary": { "type": "plain_text_input", "value": "Deploy fails on staging" }
      }
    }
  }
}
```

| Field | Type | Description |
|---|---|---|
| `viewId` | string | The `viewId` of the open modal |
| `actions` | array | Always exactly one element |
| `actions[].actionId` | string | The element's `action_id` |
| `actions[].blockId` | string | The containing block's `block_id` |
| `actions[].value` | string | Direct value for buttons, text inputs, and datepickers; empty for static selects, radios, and checkboxes |
| `state` | object | Every input's current value — see [Reading `state`](#reading-state) |

:::caution
For `static_select`, `radio_buttons`, and `checkboxes`, `actions[].value` is an
empty string. Read their selection from `state`, keyed by `block_id` then
`action_id`. A `plain_text_input` sends its text directly, and a `datepicker`
sends its `YYYY-MM-DD` value, but their current values are also present in
`state`.
:::

## `view_submission`

Sent when the user submits an open modal.

```json
{
  "type": "view_submission",
  "viewId": "create-issue",
  "user": {
    "id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
    "email": "alice@example.com",
    "accountId": 4821
  },
  "state": {
    "values": {
      "project-block": {
        "project": { "type": "static_select", "selected_option": { "value": "ROAM" } }
      },
      "summary-block": {
        "summary": { "type": "plain_text_input", "value": "Deploy fails on staging" }
      }
    }
  }
}
```

## Reading `state`

`state.values` is keyed by **`block_id`**, then by the element's
**`action_id`**:

```
state.values[block_id][action_id]
```

Each value is tagged with the element's `type`:

| Element | Value shape |
|---|---|
| `plain_text_input` | `{ "type": "plain_text_input", "value": "…" \| null }` |
| `static_select` | `{ "type": "static_select", "selected_option": { "value": "…" } \| null }` |
| `radio_buttons` | `{ "type": "radio_buttons", "selected_option": { "value": "…" } \| null }` |
| `checkboxes` | `{ "type": "checkboxes", "selected_options": [{ "value": "…" }] }` |
| `datepicker` | `{ "type": "datepicker", "selected_date": "YYYY-MM-DD" \| null }` |

:::tip
Always set `block_id` explicitly on blocks you intend to read. When you omit it,
Roam assigns `block-<index>` based on the block's position — so reordering
blocks in a modal update silently changes the keys your handler reads.
:::

## Responses

Your endpoint must respond within **3 seconds**. There are no retries,
redirects are not followed, and response bodies are capped at **1 MB**.

Respond `200` with a JSON body. The shape you send determines what the user
sees.

### To `slash_command` and `message_action`

| Body | Result |
|---|---|
| Empty | Nothing happens. A deliberate no-op. |
| `{"infoMessage": "Done"}` | A toast |
| A [modal object](#the-modal-object) | The modal opens |

```json
{ "infoMessage": "Started the deploy — I'll post here when it finishes." }
```

### To `block_actions`

| Body | Result |
|---|---|
| Empty | Nothing happens — the modal stays as it is |
| `{"responseAction": "update", "view": { … }}` | The modal is replaced in place |
| `{"infoMessage": "…"}` | A toast, modal unchanged |
| `clear` or `errors` | The response action is ignored; an included `infoMessage` is still shown |

The client does not close a modal or display per-block errors in response to a
block interaction.

### To `view_submission`

| Body | Result |
|---|---|
| Empty | The modal closes, with a **Done** toast |
| `{"responseAction": "clear", "infoMessage": "…"}` | The modal closes, and the message is shown as a toast (default: **Done**) |
| `{"responseAction": "update", "view": { … }}` | The modal is replaced in place |
| `{"responseAction": "errors", "errors": { … }}` | Per-block errors are shown, and the modal stays open |
| `{"infoMessage": "…"}` | The modal closes, and the message is shown as a toast |

Omitting `responseAction` on a `view_submission` behaves as `clear`. An
explicit successful submission looks like:

```json
{ "responseAction": "clear", "infoMessage": "Created ROAM-482" }
```

### Validation errors

Return `errors` keyed by **`block_id`**:

```json
{
  "responseAction": "errors",
  "errors": {
    "summary-block": "Summary must be under 80 characters",
    "due-block": "Pick a date in the future"
  }
}
```

The Roam client enforces required fields, lengths, and offered options as a
convenience. Treat `state` as untrusted and validate it on your server.

### Updating a modal

Return the replacement modal under `view`:

```json
{
  "responseAction": "update",
  "view": {
    "viewId": "create-issue",
    "title": { "type": "plain_text", "text": "Create Issue" },
    "blocks": [ … ],
    "submitLabel": { "type": "plain_text", "text": "Create" }
  }
}
```

This is how you build dependent fields (pick a project, then re-render with
that project's issue types) and multi-step wizards (each step returns the next
one, with a different `viewId`).

Input values are preserved across an update where the element is unchanged.
Changing an element's shape resets that field.

## Modals

### The modal object

```json
{
  "viewId": "create-issue",
  "title": { "type": "plain_text", "text": "Create Issue" },
  "blocks": [
    {
      "type": "input",
      "block_id": "summary-block",
      "label": { "type": "plain_text", "text": "Summary" },
      "element": {
        "type": "plain_text_input",
        "action_id": "summary",
        "placeholder": { "type": "plain_text", "text": "Short description" }
      }
    }
  ],
  "submitLabel": { "type": "plain_text", "text": "Create" },
  "closeLabel": { "type": "plain_text", "text": "Cancel" }
}
```

| Field | Required | Description |
|---|---|---|
| `viewId` | Yes | Your identifier for this view, up to 255 characters. Returned on every subsequent interaction. |
| `title` | Yes | `plain_text` only |
| `blocks` | Yes | The modal's content |
| `submitLabel` | No | `plain_text`. Defaults to **Submit**. |
| `closeLabel` | No | `plain_text`. Defaults to **Cancel**. |

### Carrying context through `viewId`

`block_actions` and `view_submission` do not include the chat. If your handler
needs to know where the modal was opened — to post a result with
[`chat.post`](/docs/api/chat-post), for instance — encode it into `viewId`
yourself when you open the modal:

```js
// Opening, in response to a slash_command
const viewId = `create-issue:${payload.chat.id}`;

// Submitting
const [action, chatId] = payload.viewId.split(":");
```

`viewId` allows 255 characters, which is ample for an action name and a chat
ID. Alternatively, keep server-side state keyed by a `viewId` you generate.

### Blocks

Modals use the same blocks as [Block Kit messages](/docs/guides/block-kit) —
`header`, `section`, `context`, `divider` and `actions` — plus the `input`
block for collecting values.

#### The `input` block

| Field | Required | Description |
|---|---|---|
| `type` | Yes | `"input"` |
| `label` | Yes | `plain_text` |
| `element` | Yes | One input element, below |
| `block_id` | No | Recommended. The key you read in `state` and target with `errors`. |
| `optional` | No | `true` to let the user submit without a value. Defaults to `false`. |
| `dispatch_action` | No | `true` to receive `block_actions` when this input commits a change. Defaults to `false`; see below. |

#### Input elements

| Element | Notable fields |
|---|---|
| `plain_text_input` | `placeholder`, `initial_value`, `multiline`, `min_length` (0–3000), `max_length` (1–3000), `focus_on_load` |
| `static_select` | `placeholder`, `options` (up to 100) or `option_groups`, `initial_option` |
| `radio_buttons` | `options` (up to 10), `initial_option` |
| `checkboxes` | `options` (up to 10), `initial_options` |
| `datepicker` | `placeholder`, `initial_date` (`YYYY-MM-DD`) |

Every input element accepts an `action_id` (up to 255 characters), which is the
second key under which its value appears in `state`. Set it explicitly so that
the key remains stable and meaningful to your handler.

```json
{
  "type": "input",
  "block_id": "project-block",
  "label": { "type": "plain_text", "text": "Project" },
  "dispatch_action": true,
  "element": {
    "type": "static_select",
    "action_id": "project",
    "placeholder": { "type": "plain_text", "text": "Pick a project" },
    "options": [
      { "text": { "type": "plain_text", "text": "Roam" }, "value": "ROAM" },
      { "text": { "type": "plain_text", "text": "Platform" }, "value": "PLAT" }
    ]
  }
}
```

#### `dispatch_action`

Input blocks are quiet by default: the user's edits stay client-side until they
submit. Set `dispatch_action: true` to receive a `block_actions` request when
that input commits a change. Selection elements and datepickers commit changes immediately. `plain_text_input` commits changes when it loses focus.

`dispatch_action` can be used to rebuild a modal in response to a selection.

**Interactive buttons always dispatch**, whether they are in an `actions` block
or as a `section` accessory. They do not need `dispatch_action`. A button with
a `url` opens that URL instead and does not dispatch.

## Limits

| Constraint | Limit |
|---|---|
| Actions per app | 10 |
| Action ID | 1–32 chars, `a`–`z`, `0`–`9`, `-`, `_` |
| Action label | 64 characters |
| Action description | 256 characters |
| Response time | 3 seconds, no retry |
| Response body | 1 MB |
| `viewId` | 255 characters |
| Blocks per modal | 100 |
| Total block content per modal | 256 KB |
| Any single text value | 3,000 characters |
| `errors` entries | 100 |
| `action_id` | 255 characters |

Note that modals are more generous than messages: a Block Kit **message** is
limited to 10 blocks and 8,000 bytes.

## Complete example

A `/create-issue` command that opens a modal, reacts to the project selection,
and confirms on submit. This sample handles modal `block_actions` only — not
[message-button clicks](/docs/guides/block-kit#interactivity).

```js
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.ROAM_SIGNING_SECRET);

const PROJECTS = {
  ROAM: ["Bug", "Task"],
  PLAT: ["Incident", "Chore"],
};

const modal = (chatId, project) => ({
  viewId: `create-issue:${chatId}`,
  title: { type: "plain_text", text: "Create Issue" },
  submitLabel: { type: "plain_text", text: "Create" },
  blocks: [
    {
      type: "input",
      block_id: "project-block",
      label: { type: "plain_text", text: "Project" },
      dispatch_action: true,
      element: {
        type: "static_select",
        action_id: "project",
        placeholder: { type: "plain_text", text: "Pick a project" },
        options: Object.keys(PROJECTS).map((key) => ({
          text: { type: "plain_text", text: key },
          value: key,
        })),
        ...(project && {
          initial_option: {
            text: { type: "plain_text", text: project },
            value: project,
          },
        }),
      },
    },
    ...(project
      ? [
          {
            type: "input",
            block_id: "kind-block",
            label: { type: "plain_text", text: "Issue type" },
            element: {
              type: "static_select",
              action_id: "kind",
              options: PROJECTS[project].map((kind) => ({
                text: { type: "plain_text", text: kind },
                value: kind,
              })),
            },
          },
        ]
      : []),
    {
      type: "input",
      block_id: "summary-block",
      label: { type: "plain_text", text: "Summary" },
      element: { type: "plain_text_input", action_id: "summary", max_length: 80 },
    },
  ],
});

const selected = (state, blockId, actionId) =>
  state?.values?.[blockId]?.[actionId]?.selected_option?.value;

app.post(
  "/roam/interactivity",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let payload;
    try {
      payload = wh.verify(req.body.toString("utf8"), {
        "webhook-id": req.get("webhook-id"),
        "webhook-timestamp": req.get("webhook-timestamp"),
        "webhook-signature": req.get("webhook-signature"),
      });
    } catch {
      return res.status(401).end();
    }

    const chatId = payload.viewId?.split(":")[1] ?? payload.chat?.id;

    switch (payload.type) {
      case "slash_command":
      case "message_action":
        return res.json(modal(payload.chat.id));

      case "block_actions":
        // The project changed — rebuild with that project's issue types.
        return res.json({
          responseAction: "update",
          view: modal(chatId, selected(payload.state, "project-block", "project")),
        });

      case "view_submission": {
        const summary = payload.state.values["summary-block"].summary.value;
        if (!summary?.trim()) {
          return res.json({
            responseAction: "errors",
            errors: { "summary-block": "Summary is required" },
          });
        }
        // Do the real work asynchronously, then post back with chat.post.
        createIssueLater({ chatId, summary });
        return res.json({
          responseAction: "clear",
          infoMessage: "Creating your issue…",
        });
      }

      default:
        return res.status(200).end();
    }
  }
);

app.listen(3000);
```