Apps: blocks, buttons, forms and commands
Extending Kemble
An app is registered with Kemble — its name, the scopes it asks for, the URL Kemble sends interactions to, its commands and a signing secret — and a workspace installs it. Everything it does is on behalf of a person and within what the workspace allows: an administrator can narrow its scopes and the channels it may use under Apps & agents, and switching it off stops its token, its buttons and its commands on the next request.
Post a card
curl -X POST https://kemble.io/api/apps/messages \
-H "authorization: Bearer $APP_TOKEN" \
-H 'content-type: application/json' \
-d '{
"channelId": "'$CHANNEL'",
"text": "Expense from Ali: £42 on train tickets",
"blocks": [
{ "type": "section", "text": { "format": "markdown", "text": "*Ali* spent **£42** on train tickets." } },
{ "type": "actions", "blockId": "decide", "elements": [
{ "type": "button", "actionId": "approve", "label": "Approve", "value": "expense-42", "style": "primary" },
{ "type": "button", "actionId": "reject", "label": "Reject", "value": "expense-42", "style": "danger",
"confirm": { "title": "Reject it?", "text": "Ali will be told.", "confirmLabel": "Reject", "denyLabel": "Keep it" } }
] }
]
}'text is required: it is what notifications, search and screen readers get, so write it to say what the blocks say. The blocks are drawn in its place, in Kemble Work and Kemble Circle alike.
- The token must carry chat.write, and the workspace must still grant it. It is resolved as the person who authorised the app: the app posts only where they may post, through the channel’s own permissions and overwrites, and only in the channels its installation allows.
- A direct conversation only when it is the app’s own conversation with that person — never one between two people.
- Add "visibleTo": "<user id>" to answer one person in the channel without storing anything.
- PATCH /api/apps/messages/:message with { "text", "blocks" } changes a message the app posted ("blocks": null removes them); DELETE /api/apps/messages/:message removes it.
- Sixty posts a minute per app in each workspace.
Blocks
- heading — a line of bold text, up to 150 characters.
- section — text (format markdown or plain, up to 3,000 characters), with an optional accessory: an image, a button, a select or a date picker.
- fields — up to 10 short texts, drawn in two columns.
- image — an https url and alt text, with an optional title.
- context — up to 10 small texts and images.
- divider.
- actions — up to 5 buttons, selects and date pickers.
- input — a question, in a form only: a text_input, a select or a datepicker, with a label and an optional hint.
A button has an actionId, a label and a value, and a style of default, primary or danger; add confirm to ask first. A button with a url is a link: it opens the page and sends nothing. A select’s source is static (its own options, up to 100), users (people in the workspace) or channels (channels the person can see); set multiple to allow several. A message holds up to 50 blocks and 16,384 bytes of them.
Hearing what people do
When somebody presses a button, makes a choice, picks a date, submits or closes a form, or runs one of the app’s commands, Kemble POSTs JSON to the app’s interaction URL: v, type (block_action, view_submission, view_closed or command), interactionId, appId, organizationId, userId, channelId, messageId, targetUserId, command, actions, view, triggerId, responseUrl, sentAt and message. A button’s value is always the one on the card, never one the person’s client sent.
Answer within three seconds with a 2xx. The body may be empty, or carry a response (the same shape as a response URL takes), a view to open a form, or — for a form’s submission — errors to keep it open with a message under each named question, or a view to replace it.
Check the signature
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the exact bytes received, before any JSON parsing.
function fromKemble(headers, rawBody, secret) {
const timestamp = headers['x-kemble-timestamp']
const signature = headers['x-kemble-signature'] ?? ''
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex')
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}The timestamp is inside what is signed, so a captured request cannot be replayed behind a fresh one, and a request more than five minutes old — or from five minutes in the future — should be refused. x-kemble-interaction carries the interactionId, so a repeat can be recognised.
Answer later: the response URL
curl -X POST "$RESPONSE_URL" \
-H 'content-type: application/json' \
-d '{ "text": "Approved by Ali", "replaceOriginal": true }'- replaceOriginal: true changes the message that was pressed; without blocks, the card becomes plain text.
- { "deleteOriginal": true } removes it.
- Otherwise the answer goes to the person alone ("visibility": "ephemeral", the default) or to the channel ("visibility": "channel"), where it is posted by the app — and only if the person who acted may post there.
- The URL is the authorization, like an incoming webhook’s: keep it private. It works five times in thirty minutes.
Open a form
const body = JSON.stringify({
triggerId: payload.triggerId,
view: {
v: 1, callbackId: 'reject-reason', title: 'Why reject?', submitLabel: 'Reject',
blocks: [{ type: 'input', blockId: 'reason', label: 'Reason',
element: { type: 'text_input', actionId: 'text', multiline: true, maxLength: 200 } }],
},
})
const timestamp = Math.floor(Date.now() / 1000)
const signature = createHmac('sha256', secret).update(timestamp + '.' + body).digest('hex')
await fetch('https://kemble.io/api/apps/views', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-kemble-timestamp': String(timestamp), 'x-kemble-signature': signature },
body,
})A trigger opens one form, in front of the person who acted, within ten seconds of the interaction; the request is signed the same way Kemble signs its own. To change a form that is open, send { organizationId, viewId, view } instead. A form holds up to 100 blocks; its answers arrive in view.values, by blockId and then actionId, already checked against the questions asked — a required question answered, a choice from the list, a date that is a date.
Commands
- chat — a /command in the composer; what follows the name arrives as command.text. Up to 25.
- message — in a message’s menu, under Apps; the message arrives in message, as the person reads it. Up to 5.
- user — in a person’s menu, under Apps; the person arrives as targetUserId. Up to 5.
- global — in the ⌘K palette. Up to 5.
- Running one needs message.app_commands where it is run, and the app must be allowed in that channel.