> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lithoblocks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Interactive templates over the API

> Give a template's buttons behaviour — post to a webhook destination, update the message, open a modal — when creating it through the API or an AI assistant.

A button in a template can do things when clicked: post a payload to a **webhook destination**,
**update the message** in place, or **open a modal**. In the app you configure this with the
actions planner. Over the API, the same configuration travels on the button as `action_config`,
and the API checks it before anything is stored.

## What a button can carry

```json theme={null}
{
  "type": "button",
  "text": { "type": "plain_text", "text": "Approve" },
  "action_id": "approve",
  "action_config": {
    "destination_id": "6b1e…",
    "payload_template": {
      "event": "approved",
      "request_id": "{{context.requestId}}",
      "by": "{{user.name}}",
      "at": "{{system.timestamp}}"
    },
    "idempotency_enabled": true,
    "message_update": {
      "enabled": true,
      "strategy": "replace_actions_block",
      "update_blocks": [
        { "type": "context", "elements": [{ "type": "mrkdwn", "text": ":white_check_mark: Approved by {{user.name}}" }] }
      ]
    }
  }
}
```

| Field                 | Meaning                                                                                                                                                                                                |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `destination_id`      | An **active** webhook destination in your organization (create it under Webhooks or through `/v1/destinations`). The click posts `payload_template` there.                                             |
| `route`               | Optional path appended to the destination's URL. Needs `destination_id`.                                                                                                                               |
| `payload_template`    | Any JSON. Strings may use the payload variables below.                                                                                                                                                 |
| `idempotency_enabled` | Required `true` when `message_update` is enabled; otherwise the click is ignored. Optional `idempotency_key_template` and `idempotency_ttl_hours` (1–720).                                             |
| `message_update`      | `enabled`, `strategy`, `update_blocks` (same block format as templates), `target_block_id` for `replace_block`.                                                                                        |
| `modal_config`        | `modal_definition_id` (an active modal in your organization), `data_bindings`, and an optional `submission_config` that posts to a destination and/or updates the message when the modal is submitted. |

Buttons can sit in an `actions` block, as a section `accessory`, in `context_actions`, on a card,
or on a carousel card. Where a button sits decides which update strategies make sense:

| Position                                        | Strategies                                                                                    |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `actions`, section accessory, `context_actions` | `replace_self`, `replace_actions_block`, `replace_all`, `replace_block`, `add_response_block` |
| card                                            | `replace_card`, `replace_self`, `replace_block`, `replace_all`, `add_response_block`          |
| carousel card                                   | `replace_card`, `replace_block`, `replace_all`, `add_response_block`                          |

## Payload variables

| Variable                                                                                                                                                      | Value                              |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `context.*`                                                                                                                                                   | The data the message was sent with |
| `user.id`, `user.username`, `user.name`, `user.team_id`                                                                                                       | Who clicked                        |
| `team.id`, `team.domain`                                                                                                                                      | The workspace                      |
| `interaction.action_id`, `interaction.action_value`, `interaction.channel_id`, `interaction.message_ts`, `interaction.response_url`, `interaction.trigger_id` | The click                          |
| `system.timestamp`, `system.event_type`                                                                                                                       | When, and `"button_click"`         |
| `rowData.*`                                                                                                                                                   | The `#each` item the button sat in |
| `submission.modal_id`, `submission.modal_title`, `submission.submitted_by`, `submission.submitted_at`, `submission.values.*`                                  | Modal submissions only             |

`GET /v1/templates/block-schema` returns this whole contract as data under `interactivity`, so an
assistant can read it rather than remember it.

## What the API checks

`POST /v1/templates/validate`, `POST /v1/templates` and `POST /v1/templates/{id}/versions` all
check every configured button and refuse, or report, when:

* the `action_config` has the wrong shape (an unknown strategy, `replace_block` without
  `target_block_id`, a `route` without a destination, a message update without idempotency);
* a `destination_id` is not an active destination **in your organization**, or a
  `modal_definition_id` is not one of your active modals;
* a strategy is not available for the button's position;
* the message metadata a send would embed exceeds **16,000 bytes**. Action configs travel inside
  each sent message's metadata together with the send's data, so a posted message keeps doing what
  it did when sent. `validate` reports the measured size as `interactivity.metadata_bytes`.

Validation answers with `valid: false` and one error per problem naming the `action_id`; create and
version answer `400` with `code: "invalid_data"` and the same list under `issues`. Nothing is stored
until every check passes. Before this contract, a mistake here was accepted and failed at click
time, in a Slack message only the clicker saw.

## Manage destinations

The `/v1/destinations` API supports list, get, create, patch and delete. Reads require
`destinations:read`; writes require `destinations:write` (admin or owner). Credentials go in
`headers`; `auth_type` describes the authentication style and does not synthesize an Authorization
header. URLs are readable by organization members: keep secrets in headers, not URL query strings.

```json theme={null}
{
  "name": "Approval receiver",
  "url": "https://your-receiver.example/approval",
  "method": "POST",
  "auth_type": "bearer",
  "headers": { "Authorization": "Bearer YOUR_RECEIVER_TOKEN" },
  "timeout_ms": 10000,
  "retry_enabled": false
}
```

POST this body to `/v1/destinations` with a unique `Idempotency-Key`. All responses replace header
values with `•••`. A PATCH omitting `headers` preserves credentials; a supplied object replaces the
entire header set, and `{}` clears it. Sending masked values back is rejected. Unknown fields,
invalid enums and numeric limits produce `422 validation_failed`, before database writes. Invalid
compiled modal content returns 400. Bodies over 256 KiB are rejected with 413.

DELETE returns 409 while a **current template version** references the destination, including a
modal submission webhook configured on a button. Old sent messages and draft/historical versions
are not protected by that check: deleting their destination makes subsequent clicks fail. Historical
interaction logs remain, with the deleted destination ID set to null. To stop dispatch deliberately,
PATCH `is_active: false`. Existing runtime dispatch already checks active status.

### Signed destination tests

`POST /v1/destinations/{id}/test` accepts `{ "signing_secret": "YOUR_SHARED_TEST_SECRET" }` and an
optional `Idempotency-Key`. This dispatches one real request using the destination's method and
stored headers, with a generated `lithoblocks.destination_test` payload. It does not spend a message
credit or retry automatically. The receiver's HTTP status and elapsed milliseconds are returned;
its response body is discarded. A completed non-2xx response returns `delivered: false` with that
status. A transport failure, timeout or blocked redirect returns HTTP 502: delivery is uncertain,
and an idempotency key retains that outcome for reconciliation.

Tests are disabled unless the API operator configures `INTERACTIVITY_TEST_ALLOWED_HOSTS` with exact,
comma-separated HTTPS hostnames they trust and control. No wildcard hosts, IP literals, alternate
ports, URL credentials or redirects are allowed. The operator must ensure approved hosts resolve
to public destinations and cannot be rebound to internal services. This is an operator allowlist,
not arbitrary customer-host DNS validation. No hosts are enabled by this code change.

The receiver verifies `X-Lithoblocks-Signature: v1=<hex>` as HMAC-SHA256 over
`v1:<X-Lithoblocks-Timestamp>:<raw request body>`, using the supplied test secret. Compare signatures
in constant time and reject stale timestamps/reused test IDs. The test secret is never stored or
returned. This test signature does **not** change the existing authentication protocol for ordinary
button or submission delivery; those continue to use configured headers.

## Create and version modals

`modals:read` and `modals:write` are available to members, admins and owners. Validate before saving:

```http theme={null}
POST /v1/modals/validate
Content-Type: application/json
```

```json theme={null}
{
  "title": "Review request",
  "submit_label": "Submit",
  "close_label": "Cancel",
  "blocks": [
    {
      "type": "input",
      "label": { "type": "plain_text", "text": "Reason for {{request}}" },
      "element": { "type": "plain_text_input", "action_id": "reason", "multiline": true }
    }
  ],
  "sample_data": { "request": "this change" }
}
```

Validation returns normalized `blocks`, the builder-compatible `placeholder_mapping`, and a
`compiled_view`. It uses the same compiler as `slack-modal-open`, calls no external service and
spends no credits. For creation, add `name` and POST the same content to `/v1/modals`, optionally
with `activate: true`. Definition and first version commit together or neither exists. The default
is a current draft version, so the web editor can load it. Current does not mean active: activate it before referencing it from an interactive
button; a foreign, draft, inactive or missing modal is rejected during template authoring.

* `GET /v1/modals` lists definitions; `GET /v1/modals/{id}` includes the current version.
* `PATCH /v1/modals/{id}` edits name, description, title and labels; omitted fields stay unchanged.
* `GET /v1/modals/{id}/versions` lists newest first.
* `POST /v1/modals/{id}/versions` appends `blocks`, `sample_data`, and optional `activate`.
* `POST /v1/modals/{id}/versions/{version_id}/activate` atomically selects an existing version.

Block content is versioned; activation updates the current flag, status and pointer. API edits and
the browser's `save_modal_version` RPC share a parent-row lock to serialize version numbering and
activation. Creating another draft through the API leaves the existing active version alone. The
browser RPC retains its historical behavior: every save becomes current with its supplied status.
Saving a draft in the browser therefore makes that modal unavailable to runtime opening until active.

Supported authoring blocks are input, section, context and divider, with existing modal directive
containers. Supported inputs are plain-text, static select, checkboxes, radio buttons, date and time
pickers. Title/labels allow 24 characters; compiled views allow at most 100 blocks, following
[Slack's view contract](https://docs.slack.dev/reference/views/modal-views/). The modal compiler's
`if` directive tests a truthy dot path; it does not support the message compiler's operator conditions
or `elseChildren`. Validate representative sample data for every branch. Dynamic data can still
produce an invalid Slack view later; local validation does not call Slack. Multi-step authoring and
an API modal-open/test endpoint remain outside this phase.

## Observe the flow

`GET /v1/interactions` accepts `template_id`, `action_id`, `since`, `limit` and `offset`.
`GET /v1/interactions/{id}` returns the selected outcome. Results expose safe component type/status
and timestamps, delivery status, timing and retry metadata. They omit arbitrary component data,
webhook request/response bodies, URLs, error text and raw idempotency keys.

`GET /v1/modal-submissions` accepts `modal_definition_id`, `since`, `limit` and `offset`. Organization
access is checked through **both** the owning modal and interaction log. It returns submitted values,
which can themselves contain sensitive customer input, but excludes private metadata. List responses
use `data` and `pagination: { total, limit, offset }`; `since` is inclusive ISO 8601 with a timezone.

All new routes are guarded by scopes; write/validate routes share the organization authoring rate
limit. Existing keys receive only the new read scopes permitted by their owner's role after the
migration. New write scopes require an explicit grant. Cached authentication may take up to 15
minutes to reflect a changed key. The browser API-key catalog, OpenAPI and Make scope list include
all five scopes. Make uses its existing **Make an API Call** module for these endpoints.

## Use MCP

The MCP server exposes all 17 new operations, including `list_modal_versions` and `update_modal`.
Read/validate tools are read-only. Destination tests are marked destructive and open-world;
deletions, activation and edits that affect existing clicks are destructive. Create-modal and
create-version tools are conservatively destructive because `activate: true` is an optional input.
The `interactive-flows` plugin skill walks through destination → modal → template → send → history.
`compile_template` includes configured action IDs and their effects without exposing payloads.

Destination/modal/version creation, modal activation and destination tests honor `Idempotency-Key`
(`idempotency_key` in MCP). Keys are scoped to the organization and operation. The same key and
identical body replay the recorded response; a different body or in-progress owner returns 409.
Completed responses below 500 expire after 24 hours; incomplete/5xx outcomes remain for operator
reconciliation. A separately intended operation gets a new key. Never automatically rotate keys to
bypass an uncertain outcome. PATCH operations do not use this replay store.

## Modal submission retries and delivery status

Submitting an authored modal saves one receipt per Slack workspace and modal view, then closes the
modal. Webhook and message update results arrive asynchronously in interaction history. A closed
modal confirms that the input was saved; it does not confirm that the receiver processed it.

Retries of the same view reuse the original receipt and first accepted values. Opening a new modal
permits another intentional submission, even with identical values. Webhook payloads can reference
`{{submission.id}}`, and each request includes `X-LithoBlocks-Submission-Id` for receiver correlation.
A failed message update does not cause the webhook to be sent again.

Component status `pending` means accepted work is waiting for delivery. `uncertain` means the
outcome needs review: inspect the receiver before submitting again. Failed and uncertain attempts
are not automatically retried, including when a destination has retry settings. Submission ownership
is retained with its receipt; deleting receipt history removes that protection.

The legacy `close_on_submission` field remains accepted, but saved submissions always close.
`close_behavior` controls when the message update runs after a confirmed webhook result:
`on_success` (also when no webhook is configured), `on_error`, or `always`. Unknown webhook outcomes
skip the message update. These rules apply to authored interactive modals; slash-command send forms
use a separate handler.

## Track link-button clicks

Enable **Track clicks only** beside the URL in the button editor, or set
`action_config.tracking_only: true`. The URL still opens in the user's browser. Slack sends an
interaction for URL buttons too; LithoBlocks acknowledges it and records the click without calling
another destination, opening a modal or updating the message. See the
[Slack button contract](https://docs.slack.dev/reference/block-kit/block-elements/button-element/).

```json theme={null}
{
  "type": "button",
  "text": { "type": "plain_text", "text": "Read release notes" },
  "action_id": "read_release_notes",
  "url": "https://example.com/releases/{{release_id}}",
  "action_config": { "tracking_only": true }
}
```

Place the button in an `actions` block or section accessory. It also works without a URL as a
simple acknowledgement. Tracking only cannot be combined with a webhook destination/route,
modal, or enabled message update. The editor requires those effects to be removed first.
Use a distinct action ID for each button you want to distinguish; the editor supplies one if missing.

Save the `channel` and `message_ts` returned by send, then query:

```http theme={null}
GET /v1/interactions?slack_channel_id=C123&slack_message_ts=1788880000.123456&action_id=read_release_notes
```

The same filters work in MCP `list_interactions` and the TypeScript SDK's existing interactions
list operation. Add `slack_team_id` to disambiguate Slack workspaces. The result contains who clicked,
when, the exact action ID and message coordinates, and a `click_tracking` component with `success`.
It uses existing `interactions:read` permissions and organization isolation. `pagination.total`
counts records matching the filters; paginate to collect records and deduplicate `slack_user_id`
locally if you want unique people. New clicks by the same person count again. `since` is inclusive;
when polling, overlap the time window and deduplicate by interaction `id`.

Slack retries with the same team, channel, message, user, action ID and exact action timestamp share
one stored record. Distinct clicks count separately, even in the same millisecond. No webhook
destination, webhook idempotency key, extra credit or background delivery worker is needed.
This records receipt of a click, not a page view, successful page load, read receipt, or conversion.
URL navigation can succeed even if tracking is unavailable; failed persistence is not acknowledged
as successful, and collection is not guaranteed during outages. There is no automatic alert subscription
or aggregate analytics endpoint in this addition. Receipt protection lasts while interaction history
is retained; deletion removes that protection. Buttons in messages sent before tracking was enabled
keep their original configuration—publish a version and send a new message to enable it.
