> ## 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.

# Slash commands

> Give members a /command in chat. They fill in typed options, and your app answers in the chat or privately to them, with content and an optional live block.

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

A slash command is something a member runs from the chat composer, such as `/deploy api production`.
They type `/`, pick your command, and fill in its options in place. Reintersect sends your app the
command with typed option values, and your app answers with content, an optional live block and a
short notice. The answer can be public, posted in the chat as your app, or private, shown only to the
member who ran the command.

Using Effect? See [Commands](/effect/commands-and-events#commands) and
[Deferred answers](/effect/deferring) in the Effect guide.

## Define a command

```tsx src/deployCommand.tsx theme={null}
import { createReintersectApp, defineCommand } from "@reintersect/sdk/app";
import { Bold, Paragraph } from "@reintersect/sdk/components";
import { deployment } from "@reintersect/sdk/recipes";
import { z } from "zod";
import { startDeploy } from "./ci";

export const Deploy = deployment({ type: "deploy", title: "Deployment" });

export const DeployCommand = defineCommand(
  "deploy",
  {
    description: "Deploy a service",
    options: z.object({
      service: z.string().describe("The service to deploy"),
      env: z.enum(["staging", "production"]).describe("Where to deploy it"),
      dryRun: z.boolean().optional().describe("Plan the deploy without running it"),
    }),
  },
  async ({ options }) => {
    const run = await startDeploy(options.service, options.env, options.dryRun ?? false);

    return {
      visibility: "public",
      content: (
        <Paragraph>
          Deploying <Bold>{options.service}</Bold> to {options.env}.
        </Paragraph>
      ),
      block: Deploy.insert({
        service: options.service,
        environment: options.env,
        logsUrl: run.logsUrl,
      }),
    };
  },
);

export const app = createReintersectApp({ name: "Deploys" })
  .addBlock(Deploy)
  .addCommand(DeployCommand);
```

`options.service` is a `string`, `options.env` is `"staging" | "production"` and `options.dryRun` is
`boolean | undefined`: the types come from the schemas you declare. Because the answer inserts
`Deploy`, the app must include that block, the same as for [message actions](/message-actions) and
events. See [Composing an app](/concepts/composition#blocks-inside-other-pieces).

## Options

| Option | Type | Description |
| - | - | - |
| `description` | `string` | Shown next to the command in the slash menu. 1 to 100 characters. |
| `options` | Object schema | The values a member fills in. See [Option schemas](#option-schemas). |
| `scopes` | `Scope[]` | Scopes the handler's client uses. A command needs none to answer. |
| `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. |
| `progress` | `{ visibility? }` | Who sees your app thinking when a [streamed answer](/progress-updates) is slow to start. Private unless you pass `"public"`. |

The name (`"deploy"`) is what members type after `/`: a lowercase letter, then up to 31 lowercase
letters, digits or `-`. Names are unique within the app. An app has at most 25 commands.

### Option schemas

Options are one object schema, the same kind your blocks use for props: Zod `z.object({...})`,
ArkType `type({...})`, or Valibot `v.object({...})` wrapped in `toStandardJsonSchema()`. Each
property is an option, and an optional property (`.optional()` in Zod and Valibot, `"dryRun?"` in
ArkType) is an optional option. Every option needs a description, which the member sees in the
composer: `.describe()` in Zod and ArkType, `v.description()` in Valibot.

The SDK reads each property's JSON Schema and checks the values against your schema before your
handler runs, so `options` holds the schema's output. A value that fails is answered with
`422 invalid_options`, and the member sees a failure card.

| Property | Option | What the member picks | Value your handler receives |
| - | - | - | - |
| A string, with any length limits | `string` | Free text, trimmed, 1 to 1,000 characters | `string` |
| An integer | `integer` | A whole number | `number` |
| A number | `number` | Any finite number | `number` |
| A boolean | `boolean` | Yes or no | `boolean` |
| A union or enum of string literals | `choice` | One of 1 to 25 values | The picked value |
| `member(schema)` | `member` | A member of the workspace | See [Pickers](#pickers) |
| `circle(schema)` | `circle` | A public circle, or a private circle the member is in | See [Pickers](#pickers) |
| `appObject(kind, schema)` | `appObject` | One of your objects, found with your reference's `search` | See [Pickers](#pickers) |

#### Choice labels

Each value of a choice shows its JSON Schema `title`, or the value with a capital first letter when it
has none, so `"early-access"` reads "Early access". To pick the labels, give each literal a title:

<CodeGroup>
  ```ts Zod theme={null}
  z.union([z.literal("prod").meta({ title: "Production" }), z.literal("staging").meta({ title: "Staging" })])
  ```

  ```ts ArkType theme={null}
  type("'prod'").configure({ title: "Production" }).or(type("'staging'").configure({ title: "Staging" }))
  ```

  ```ts Valibot theme={null}
  v.union([v.pipe(v.literal("prod"), v.title("Production")), v.pipe(v.literal("staging"), v.title("Staging"))])
  ```
</CodeGroup>

#### Pickers

`member()`, `circle()` and `appObject(kind)` mark a Zod or ArkType schema as a picker. They add
metadata that survives into the JSON Schema, so the option stays a plain property of your object
schema. In Valibot, add the same metadata with `v.metadata(memberOption)`, `v.metadata(circleOption)`
or `v.metadata(appObjectOption(kind))`.

The schema you mark decides what your handler receives. An object schema parses the whole picked
value, so your handler gets that schema's output:

| Picker | A string schema receives | An object schema parses |
| - | - | - |
| `member` | The member ID | `{ kind: "member", memberId, name }` |
| `circle` | The circle ID | `{ kind: "circle", circleId, name }` |
| `appObject` | The object's `id` | `{ kind: "appObject", id, title }` |

```ts theme={null}
import { appObject, member } from "@reintersect/sdk/app";
import { z } from "zod";

options: z.object({
  owner: member(z.string()).describe("Who owns the deploy"),
  build: appObject("build", z.object({ id: z.string(), title: z.string() }))
    .optional()
    .describe("The build"),
}),
```

#### Order

Options appear in the composer in the order you declare them, with required options first.

ArkType object schemas sort their keys, so their options appear required first, then
alphabetically. To keep the order you wrote, pass a record with one schema per key, and wrap optional
ones in `optional()`:

```ts theme={null}
import { choice, optional } from "@reintersect/sdk/app";
import { type } from "arktype";

options: {
  service: type("string").describe("The service to deploy"),
  env: choice({ staging: "Staging", production: "Production" }, { description: "Where to deploy it" }),
  dryRun: optional(type("boolean").describe("Plan the deploy without running it")),
},
```

#### Shapes that can't be options

`defineCommand` throws when an option can't be shown in the composer, and names the option: a nested
object that isn't a picker, an array, a union of anything other than string literals, or a missing
description. `reintersect check` reports the same error. A command has at most 25 options. Option
names follow the same rule as message action ids: 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_`,
`.` and `-`.

Optional options a member skips are absent from `options`.

Reintersect checks every value before it calls you. It looks members and circles up again and
supplies their names itself, so a member can't send a name of their choosing. A choice must be one
of the values you declared.

For `appObject`, `kind` names one of your app's [reference kinds](/references/overview), and that
reference needs a [`search`](/references/search) function so members can find objects. The `id` is
the `url` your search returned for the pick. Treat it like any other input: look the object up in
your own system and check that the member may use it.

## Handler context

| Field | Type | Description |
| - | - | - |
| `command` | `string` | The command's name. |
| `options` | Typed | The option values, as described above. |
| `surface` | `{ kind: "circle", circleId, conversationId }` or `{ kind: "dm", partnerMemberId }` | Where the member ran the command. `conversationId` is `null` when they ran it in the circle's chat rather than inside a conversation. |
| `invocation` | `{ id, interactionToken }` | Identifies this run. Pass `interactionToken` to answer later. |
| `defer` | `(task?, { visibility? }) => Deferred` | Answer after the delivery returns. See [Deferring](#deferring). |
| `actor` | `{ memberId, name }` | The member who ran the command. |
| `reintersect` | Scoped client | A client with the declared scopes. |
| `env`, `config` | Typed | The declared environment variables and install config. |
| `waitUntil` | `(task) => void` | Keeps work running after the handler returns. |

## Answers

Return an answer, a deferral, or nothing:

| Field | Type | Description |
| - | - | - |
| `visibility` | `"public" \| "private"` | Required with `content` or `block`. |
| `content` | JSX | A document, written with the same components as [replies](/api-reference/replies/create). Links, references and mentions of members, and of circles and conversations your app can reach, are allowed. Files and images aren't; link to them instead. |
| `block` | `Block.insert(props)` or block JSX | At most one block. The member who ran the command is its creator, so `audience="creator"` parts are theirs. The block doesn't need to be `insertable`. |
| `notice` | `string` or `{ tone, text }` | A short message, at most 140 characters, with a tone of `neutral`, `info`, `success`, `warning` or `danger`. Only the member who ran the command sees it, where the command ran, until they dismiss it or a day passes. In a private answer it sits above your content. |

<Tabs>
  <Tab title="Public">
    ```tsx theme={null}
    ({ options }) => ({
      visibility: "public",
      content: <Paragraph>Paging the on-call for {options.service}.</Paragraph>,
    })
    ```

    Reintersect posts the answer as a message from your app, headed with who asked, such as
    "Ada used /deploy · api · production". In a circle it goes into the conversation the member was
    in, or into the circle's chat; in a direct message it goes into that DM.
  </Tab>

  <Tab title="Private">
    ```tsx theme={null}
    ({ options }) => ({
      visibility: "private",
      content: <Paragraph>{options.service} has 3 deploys queued.</Paragraph>,
      block: Deploy.insert({ service: options.service, environment: "staging" }),
    })
    ```

    Only the member who ran the command sees the answer, in a card marked "Only you can see this".
    The block in it is live and interactive. The member can share the card into the chat, which posts
    it publicly with the same header, or dismiss it. An unshared card goes away after 24 hours.
  </Tab>

  <Tab title="Notice only">
    ```ts theme={null}
    async ({ options }) => {
      await queueDeploy(options.service);
      return { notice: "Deploy queued" };
    }
    ```

    A notice on its own posts nothing; the member who ran the command sees it with your app's icon.
    A `danger` or `warning` notice on its own ends the run as failed: the member sees a failure card
    with your text and can run the command again.
  </Tab>
</Tabs>

Returning nothing finishes the run without posting anything.

## Timing

Reintersect waits up to 30 seconds for an answer and doesn't retry. If your app fails, answers with
an error or takes longer, the member sees a failure card with **Run again**. After 10 seconds the
member sees that your app is still working.

### Deferring

For work that takes longer, defer. Pass `defer` a function that returns the answer:

```tsx theme={null}
export const ReportCommand = defineCommand(
  "report",
  { description: "Build this week's report" },
  ({ defer }) =>
    defer(
      async () => {
        const report = await buildReport();

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

The SDK answers the delivery with `202` at once, runs the function with `waitUntil`, and sends its
answer with [`POST /v0/invocations/respond`](/api-reference/invocations/respond). Until the answer
arrives, your app's row reads "Deploys is thinking…". You have 15 minutes.

By default only the member who ran the command sees your app thinking. Pass `{ visibility: "public" }`
as the second argument, as above, to show that row to everyone in the circle or direct message. It's
opt-in because the row shows which command ran and with which options, so declare it only when the
answer will be public too.

The deferral's visibility only decides who sees your app thinking. The answer keeps its own
`visibility`: a private answer after a public deferral still reaches only the member who ran the
command, and the thinking row goes away for everyone else. Before your app answers the delivery, only
the member who ran the command sees that it's running.

If the function throws, or returns an answer the SDK can't send, the SDK answers for you with a
private `danger` notice: "Couldn't finish this. Try again." The member sees the failure card with
**Run again** right away, and the error goes to your logs. To say what went wrong in your own words,
catch the failure and return your own notice:

```tsx theme={null}
defer(
  async () =>
    buildReport().then(
      (report) => ({ visibility: "public", content: <Paragraph>{report.summary}</Paragraph> }),
      () => ({ notice: { tone: "danger", text: "The report service is down" } }),
    ),
  { visibility: "public" },
)
```

When the work happens somewhere else, such as a queue worker, call `defer()` with no function, or
`defer(undefined, { visibility: "public" })`, and pass `invocation.interactionToken` along. Answer
from anywhere with the app client:

```ts theme={null}
import { createReintersectClient } from "@reintersect/sdk";
import { app } from "./app";

export const finishReport = async (installId: string, interactionToken: string, summary: string) => {
  const reintersect = createReintersectClient(app, { installId });

  return reintersect.invocations.respond({
    interactionToken,
    answer: { visibility: "public", content: summary },
  });
};
```

A run takes one answer. A second answer, or one after the 15 minutes, fails with
`ReintersectAppInteractionExpiredError`. Hosted runtimes keep `waitUntil` work alive for a limited
time after the response, so hand work that can take minutes to a queue.

## Where commands show

Members run commands from the composer in circle chats and in direct messages, in workspaces where
the app is installed. A development install shows its commands only to its developer.

In a circle, running a command invites your app there, like a mention or a
[message action](/message-actions): a member can run it in any public or private circle they are in,
and your app [reaches](/concepts/install-and-consent#reach) that circle from then on. A development
install runs only in circles its developer is in. Commands never run in circles connected to another
workspace or a Slack channel.

A command sends your app only what the member picked, so adding or changing commands never needs an
admin's approval. See [Publishing new versions](/concepts/install-and-consent#publishing-new-versions).

## Over the wire

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

## Testing

```ts theme={null}
const outcome = await host.runCommand("deploy", { service: "api", env: "staging" });

expect(outcome).toMatchObject({
  status: "answered",
  visibility: "public",
  block: { type: "deploy", props: { service: "api", environment: "staging" } },
});
```

A deferred command returns `{ status: "deferred", visibility, interactionToken }`. Call `host.settle()` and read
the answer with `host.expectPushed("invocations.respond")`. When the deferred task fails, that answer
is the SDK's failure notice. See [Testing](/testing).


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