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

# Message actions

> Add items to the menu on chat messages, direct messages, posts and replies. Your app receives the message, and as much context around it as you ask for, and answers in place.

> Reintersect apps are in developer preview. Names and limits can still change before general
> release.

A message action is an item in the menu on a message, such as **File an issue** or **Summarize
this**. When a member picks it, Reintersect sends your app the message, plus its thread or its
conversation when you ask for them, and your app answers with content, a live block or a short
notice. Answers work like [slash command](/commands) answers: public, as a reply from your app, or
private, shown only to the member who ran the action.

Using Effect? See [Message actions](/effect/commands-and-events#message-actions) in the Effect guide.

## Define a message action

```tsx src/requestApproval.tsx theme={null}
import { createReintersectApp, defineMessageAction } from "@reintersect/sdk/app";
import { approval } from "@reintersect/sdk/recipes";

export const ExpenseApproval = approval({ type: "expense-approval", title: "Expense approval" });

export const RequestApproval = defineMessageAction(
  "request-approval",
  { label: "Request approval", icon: "shield-check" },
  ({ message }) => ({
    visibility: "public",
    block: ExpenseApproval.insert({
      title: message.text,
      requestedBy: message.author.displayName,
      approvers: ["member_finance_lead"],
    }),
  }),
);

export const app = createReintersectApp({ name: "Expenses" })
  .addBlock(ExpenseApproval)
  .addMessageAction(RequestApproval);
```

The id (`"request-approval"`) is unique within the app, 1 to 64 characters from `A-Z`, `a-z`, `0-9`,
`_`, `.` and `-`. An app has at most 10 message actions.

Because the handler answers with `ExpenseApproval.insert(…)`, the app must also include that block.
Leaving it out makes the app incomplete, and `app.fetch` reports `block:expense-approval` as missing.
See [Composing an app](/concepts/composition#requirements-checked-by-typescript).

## Options

| Option | Type | Description |
| - | - | - |
| `label` | `string` | The menu label, at most 80 characters. Start with a verb: "File an issue". |
| `icon` | `IconName` | Optional icon next to the label. |
| `context` | `"message" \| "thread" \| "conversation"` | How much around the message your app receives. Defaults to `"message"`. See [Context](#context). |
| `scopes` | `Scope[]` | Scopes the handler's client uses. `conversations:read` is always included. |
| `blocks` | `{ Name: Block }` | Blocks the handler's client inserts or updates, so the client is typed for them. |
| `env`, `config` | | Environment variables and install config the handler reads. |

## Context

The menu tells the member what the action sends: "Sends this message", "Sends this thread", "Sends
this conversation" or "Sends the chat around this". Ask for the least your action needs.

| `context` | Your handler receives |
| - | - |
| `"message"` | The message only. |
| `"thread"` | `thread`: the message's reply chain, from its first message down, at most 40 messages. In chat and direct messages that is the inline replies; on a board, the replies under a reply. |
| `"conversation"` | `conversation`: `{ id, title, status, agreed, participants, messages }` with the latest 40 messages, when the message is in a conversation. For a chat message not filed in a conversation, or a direct message, `surrounding` instead: about 30 messages around it. |

`agreed` is the conversation's Agreed statement, or `null`. Each context message is
`{ id, text, author, createdAt }`, with plain text of at most 2,000 characters, and the newest
messages are kept within a budget of 32,000 characters.

```tsx src/summarize.tsx theme={null}
import { defineMessageAction } from "@reintersect/sdk/app";
import { Paragraph } from "@reintersect/sdk/components";
import { summarize } from "./summarizer";

export const Summarize = defineMessageAction(
  "summarize",
  { label: "Summarize this", icon: "sparkles", context: "conversation" },
  async ({ context }) => {
    const messages =
      context.level === "conversation"
        ? (context.conversation?.messages ?? context.surrounding ?? [])
        : [context.message];
    const summary = await summarize(messages.map(({ author, text }) => `${author.displayName}: ${text}`));

    return {
      visibility: "private",
      content: <Paragraph>{summary}</Paragraph>,
    };
  },
);
```

`context` is typed by what you declare: an action declared with `"thread"` can receive the message or
thread levels, never the conversation. Always check `context.level`. An install that hasn't approved
a wider context yet still runs the version that asked for less, and receives that narrower level.

### Wider context needs approval

Asking for more context is an access change. Moving an action from `"message"` to `"thread"`, or to
`"conversation"`, makes installs stay on their current version until a workspace admin approves the
new access, and your app receives `app.reconsentRequired`. `reintersect check --app` warns about it
before you deploy (`access-widened`). Asking for less never needs approval. See
[Publishing new versions](/concepts/install-and-consent#publishing-new-versions).

## Handler context

| Field | Type | Description |
| - | - | - |
| `message` | `{ id, conversationId, text, author }` | The message the member picked the action on. `text` is its plain text; `author` is `{ id, kind, memberId, displayName }`. `conversationId` is `null` for a chat message not filed in a conversation and for a direct message. |
| `context` | `{ level, message, thread?, conversation?, surrounding? }` | See [Context](#context). |
| `surface` | `{ kind: "circle", circleId, conversationId }` or `{ kind: "dm", partnerMemberId }` | Where the message is. |
| `invocation` | `{ id, interactionToken }` | Identifies this run. Pass `interactionToken` to answer later. |
| `defer` | `(task?, { visibility? }) => Deferred` | Answer after the delivery returns. See [Deferring](/commands#deferring). |
| `actor` | `{ memberId, name }` | The member who picked the action. |
| `reintersect` | Scoped client | A client with `conversations:read` plus the declared scopes. |
| `env`, `config` | Typed | The declared environment variables and install config. |
| `waitUntil` | `(task) => void` | Keeps work running after the handler returns. |

## Answers

A message action answers exactly like a [slash command](/commands#answers):

| Field | Type | Description |
| - | - | - |
| `visibility` | `"public" \| "private"` | Required with `content` or `block`. |
| `content` | JSX | A document, with the same rules as [replies](/api-reference/replies/create). Files and images aren't allowed; link to them instead. |
| `block` | `Block.insert(props)` or block JSX | At most one block. The member who ran the action is its creator, so `audience="creator"` parts are theirs. The block doesn't need to be `insertable`. |
| `notice` | `string` or `{ tone, text }` | A short confirmation for the member, at most 140 characters. Only the member who ran the action sees it: under the message in chat and direct messages, as a toast on a posts circle. |

<Tabs>
  <Tab title="Public">
    ```tsx theme={null}
    import { Bold, Paragraph } from "@reintersect/sdk/components";

    ({ message }) => ({
      visibility: "public",
      content: (
        <Paragraph>
          Logged <Bold>{message.text.slice(0, 80)}</Bold> to the release notes.
        </Paragraph>
      ),
    })
    ```

    Reintersect posts the answer as your app, headed with who asked, such as "Ada ran Log release
    note". In a chat or a direct message it's an inline reply to the message; on a board it's a
    reply in the same thread.
  </Tab>

  <Tab title="Private">
    ```tsx theme={null}
    ({ message }) => ({
      visibility: "private",
      block: TicketPreview.insert({ title: message.text }),
    })
    ```

    Only the member who ran the action sees the answer, in a card marked "Only you can see this"
    under the message. They can share it into the chat or dismiss it. An unshared card goes away
    after 24 hours.
  </Tab>

  <Tab title="Notice only">
    ```ts theme={null}
    async ({ message }) => {
      await addToReleaseNotes(message.text);
      return { notice: "Added to the release notes" };
    }
    ```

    The member sees a short confirmation and nothing is posted. A `danger` or `warning` notice on its
    own reports the run as failed, with your text.
  </Tab>
</Tabs>

Returning nothing finishes the run without feedback. Prefer a notice so the member knows the action
ran. An answer from a message action needs no extra scope: it answers the member's request.

## Timing

Reintersect waits up to 30 seconds for an answer and doesn't retry. For slower work, `defer`, exactly
as for [commands](/commands#deferring):

```tsx theme={null}
({ defer, message }) =>
  defer(
    async () => ({
      visibility: "public",
      content: <Paragraph>{await fileIssue(message.text)}</Paragraph>,
    }),
    { visibility: "public" },
  )
```

Only the member who picked the action sees your app thinking, unless you pass
`{ visibility: "public" }` as the second argument, as above, to show it to everyone who can see the
message. It's opt-in because the row shows which action ran. The answer keeps its own `visibility`
either way.

You have 15 minutes to answer through
[`POST /v0/invocations/respond`](/api-reference/invocations/respond), which `defer` and
`reintersect.invocations.respond` call for you. If the deferred task throws or fails, the SDK answers
with a generic private `danger` notice so the member isn't left waiting. Catch the failure inside the
task to answer your own way.

## Who sees the item

Message actions appear on circle chat messages, posts and replies, and direct messages, for members
who can see the message, in workspaces where the app is installed. A development install shows its
message actions only to its developer. Message actions aren't available in circles connected to
another workspace or a Slack channel.

Running a message action in a private circle [invites](/concepts/install-and-consent#reach) your app
to that circle, so it can reply there and receive that circle's events afterwards.

## Over the wire

Without the SDK, a message action arrives as a `messageAction.run` delivery with
`{ id, message, context, surface, invocationId, interactionToken }`, and your endpoint answers `200`
with `{ visibility, content?, block?: { type, props }, notice? }`, or `202` with `{ visibility }` (who
sees your app thinking: `"public"` or `"private"`) and answers later. See the [delivery protocol](/reference/delivery-protocol#commands-and-message-actions).

## Testing

```ts theme={null}
const outcome = await host.runMessageAction("request-approval", { text: "New laptop, 1,900 EUR" });

expect(outcome).toMatchObject({ status: "answered", block: { type: "expense-approval" } });
```

Pass the thread or conversation your action should see as the third argument:

```ts theme={null}
await host.runMessageAction(
  "summarize",
  { text: "Ship it" },
  { context: { conversation: { title: "Launch plan", messages: [{ text: "Docs are ready" }] } } },
);
```

See [Testing](/testing#commands-and-message-actions).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.