Skip to main content
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 and Deferred answers in the Effect guide.

Define a command

src/deployCommand.tsx
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 and events. See Composing an app.

Options

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.

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:

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:

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():

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, and that reference needs a 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

Answers

Return an answer, a deferral, or nothing:
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.
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:
The SDK answers the delivery with 202 at once, runs the function with waitUntil, and sends its answer with POST /v0/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:
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:
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: a member can run it in any public or private circle they are in, and your app reaches 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.

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.

Testing

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.