# Slash commands and modals

> Handle commands with channel.onCommand — arguments arrive as raw text (options is populated only on structured surfaces like Discord) — hand them to the agent explicitly, and open modals with ctx.openModal calling Modal({...}) as a function (not <Modal> JSX), routing submissions by callbackId to onModalSubmit/onModalClose.

- Repository: CopilotKit/channels-sdk
- GitHub: https://github.com/CopilotKit/channels-sdk
- Human docs: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161
- Complete Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/llms-full.txt

## Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/evals/evals.json`

---

---
title: "Slash commands and modals"
description: "Handle commands with channel.onCommand — arguments arrive as raw text (options is populated only on structured surfaces like Discord) — hand them to the agent explicitly, and open modals with ctx.openModal calling Modal({...}) as a function (not <Modal> JSX), routing submissions by callbackId to onModalSubmit/onModalClose."
---

`channel.onCommand(name, fn)` registers a slash-command handler on the object `createChannel` returned; the handler receives a `CommandContext` whose argument payload is the raw `text` string after the command name, plus an optional `openModal(view)` trigger. Modals are a separate IR root (`ModalView`), not a message: build the view by calling `Modal({...})` as a plain function, open it with `openModal?.(...)`, and receive submissions and dismissals on `channel.onModalSubmit(callbackId, fn)` / `channel.onModalClose(callbackId, fn)` — routed by `callbackId`, never by inline handlers.

<Note>
Managed slash commands are not part of the managed-Channel product surface today. Commands reach your process through a platform connection you own — for Slack that means the direct adapter over Socket Mode — and adapters expose command registration through the optional `registerCommands` capability.
</Note>

## Handle a command

```tsx title="commands.tsx"
channel.onCommand("top", async ({ thread, text }) => {
  const stories = await fetchTopStories(Number(text) || 5);
  await thread.post(/* a <Message> listing them */);
});
```

Files containing JSX must be `.tsx` and compile with `jsxImportSource: "@copilotkit/channels"`. Handlers must return `void | Promise<void>` — a concise arrow returning `thread.post(...)` fails under `strict` because `post` returns a `MessageRef`; use a block body and `await` the post.

### CommandContext

<ParamField body="text" type="string">
The raw argument string after the command name. This is where arguments arrive on text-only surfaces such as Slack.
</ParamField>

<ParamField body="options" type="object">
The parsed, typed argument form. Populated only by surfaces that deliver structured arguments natively (Discord); empty on text-only surfaces — read `text` there.
</ParamField>

<ParamField body="command" type="string">
The command name that fired.
</ParamField>

<ParamField body="user" type="object">
The invoking user.
</ParamField>

<ParamField body="actor" type="object">
The platform actor for the invocation.
</ParamField>

<ParamField body="platform" type="string">
The originating platform surface.
</ParamField>

<ParamField body="openModal" type="(view: ModalView) => Promise<void>">
Optional. `undefined` on surfaces with no modal trigger — always call it as `openModal?.(...)`.
</ParamField>

<Warning>
There is no `ctx.args` field. Reading slash-command arguments from `args` is a documented failure mode — use `text` (raw) or `options` (typed, structured surfaces only).
</Warning>

### Hand arguments to the agent explicitly

Command arguments are never posted to the channel, so they do not appear in reconstructed conversation history. `thread.runAgent()` with an omitted prompt defaults to the inbound message content — which for a command is not the arguments. Pass them yourself:

```ts
channel.onCommand("triage", async ({ thread, text }) => {
  await thread.runAgent({ prompt: `Triage: ${text}` });
});
```

### Richer command metadata

For a description and an `options` schema registered with the platform, define the command with `defineChannelCommand` and pass it via `createChannel({ commands })`. The identifier is `defineChannelCommand` — `defineBotCommand` does not exist.

## Open a modal

### Call `Modal(...)`, don't write `<Modal>`

The Channels JSX runtime declares `JSX.Element = ChannelNode`, so every JSX expression is typed `ChannelNode` — which erases the `ModalView` narrowing that `openModal` requires. `<Modal …/>` therefore fails under `strict`:

```
error TS2345: Argument of type 'ChannelNode' is not assignable to parameter of type 'ModalView'.
```

Call the component as a plain function and pass `children` as a prop. The children may still be JSX, because only the root must remain a `ModalView`. Do not paper over the error with `as ModalView` or `as any`.

```tsx title="feedback-modal.tsx"
channel.onCommand("feedback", async ({ openModal }) => {
  await openModal?.(
    Modal({
      callbackId: "feedback",
      title: "Send feedback",
      submitLabel: "Send",
      children: [
        <TextInput id="body" label="What happened?" multiline />,
        <RadioButtons id="severity" label="Severity">
          <ModalSelectOption label="Blocking" value="high" />
          <ModalSelectOption label="Annoying" value="low" />
        </RadioButtons>,
      ],
    }),
  );
});

channel.onModalSubmit("feedback", async ({ values, thread }) => {
  if (!values.body) return { errors: { body: "Tell us what happened." } };
  await thread?.post(<Section>Thanks — logged it.</Section>);
});
```

### Modal components

All modal components import from the `@copilotkit/channels` root (UI is also available at `/ui`).

| Component | Props | Notes |
| --- | --- | --- |
| `Modal` | `callbackId: string`, `title: string`, `submitLabel?`, `closeLabel?`, `notifyOnClose?`, `privateMetadata?` | The view root. `notifyOnClose` makes Slack emit `view_closed`. `privateMetadata` is an opaque string echoed back to the handlers. |
| `TextInput` | `id: string`, `label: string`, `placeholder?`, `multiline?`, `optional?`, `maxLength?`, `initialValue?` | Free-text field. Read it from `evt.values[id]`. |
| `ModalSelect` | `id: string`, `label: string`, `placeholder?`, `optional?`, `initialOption?` | Children are `ModalSelectOption`. `initialOption` is an option's `value`. |
| `ModalSelectOption` | `label: string`, `value: string` | |
| `RadioButtons` | `id: string`, `label: string`, `optional?`, `initialOption?` | Children are `ModalSelectOption`. |

Unlike message rendering — where an adapter's renderer is total and silently skips unsupported nodes — a modal view that uses an element the surface cannot express makes the adapter throw `ModalRenderError`. Modals do not skip-and-degrade.

## Route submissions by callbackId

Modal results never dispatch to inline handlers. The engine matches the view's `callbackId` against handlers registered up front:

| Handler | Fires when | Handler gets |
| --- | --- | --- |
| `channel.onModalSubmit(callbackId, fn)` | the modal is submitted | `ModalSubmitEvent` — `values` keyed by field `id`, optional `thread`, echoed `privateMetadata` |
| `channel.onModalClose(callbackId, fn)` | the modal is dismissed | `ModalCloseEvent` (Slack requires `notifyOnClose` on the view) |

Return `{ errors }` from a submit handler — an object mapping field `id` to a message — to keep the modal open with inline validation errors. `thread` is optional on `ModalSubmitEvent`: a submission may arrive without a conversation context, so guard with `thread?.` before posting.

```mermaid
sequenceDiagram
    participant U as User
    participant P as Platform surface
    participant C as Your Channel process
    U->>P: /feedback something broke
    P->>C: command event
    C->>C: channel.onCommand("feedback", ctx)
    C->>P: ctx.openModal(Modal({ callbackId: "feedback", ... }))
    U->>P: fills fields, submits
    P->>C: view submission
    C->>C: channel.onModalSubmit("feedback", { values, thread? })
    alt validation fails
        C-->>P: return { errors } — modal stays open
    else success
        C->>P: thread?.post(<Section>…</Section>)
    end
```

## Troubleshooting

<AccordionGroup>
<Accordion title="TS2345: 'ChannelNode' is not assignable to 'ModalView'">
You wrote `<Modal …>` JSX. Call `Modal({ callbackId, title, children: [...] })` as a function instead; only the root needs the `ModalView` type, so the children can stay JSX.
</Accordion>
<Accordion title="openModal is undefined">
`openModal` is optional on `CommandContext` and is `undefined` on surfaces with no modal trigger. Keep the optional call `openModal?.(...)` and design a message-based fallback if the flow must work everywhere.
</Accordion>
<Accordion title="Command arguments are empty">
On text-only surfaces `options` is empty — read `ctx.text`. And `ctx.args` does not exist at all.
</Accordion>
<Accordion title="onModalClose never fires on Slack">
Slack only emits `view_closed` when the view sets `notifyOnClose` on `Modal`.
</Accordion>
<Accordion title="The agent doesn't see the command arguments">
Command args are not posted to the channel, so reconstructed history omits them. Pass them explicitly: `thread.runAgent({ prompt: \`…${text}\` })`.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
The full ten-handler surface onCommand, onModalSubmit, and onModalClose belong to, plus the runAgent defaults.
</Card>
<Card title="UI components reference" href="/ui-components-reference">
Every layout, interactive, and modal component with props and degradation rules.
</Card>
<Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
Why slash commands ride the direct-adapter path and what the managed surface covers.
</Card>
<Card title="createChannel reference" href="/createchannel-reference">
The `commands` option and the rest of the createChannel configuration.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
The TS2345 ModalView narrowing error and other documented compile-time failures.
</Card>
</CardGroup>
