# Cmdk Actions

> Guide for adding new actions to Sentry's Command+K palette. Use when implementing new cmdk actions, registering page-level or global actions, building async resource pickers, or adding contextual actions to a view.

- Skill: `tuyv/cmdk-actions` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add tuyv/cmdk-actions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tuyv/cmdk-actions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tuyv (https://skillmd.com/u/tuyv)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tuyv/cmdk-actions

---


# Adding Actions to the Command Palette (cmdk)

Sentry's Command+K palette is built on a tree-collection system where `CMDKAction` components register themselves via React context. Actions render wherever in the component tree they live — no central registry to update.

## Core files

- **`static/app/components/commandPalette/ui/cmdk.tsx`** — `CMDKAction` component (the only primitive you need)
- **`static/app/components/commandPalette/types.tsx`** — public types + `cmdkQueryOptions` helper
- **`static/app/components/commandPalette/ui/commandPaletteSlot.tsx`** — `CommandPaletteSlot` for scoping
- **`static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx`** — always-on global actions

---

## The Three Slots

Slots control sort order and lifetime. Import from `commandPaletteSlot.tsx`:

```tsx
import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot';
```

| Slot     | Order in palette         | Lifetime                                   | Use for                                                                 |
| -------- | ------------------------ | ------------------------------------------ | ----------------------------------------------------------------------- |
| `task`   | First (highest priority) | Reserved — not yet used in production      | Future transient workflow steps                                         |
| `page`   | Second                   | Tied to the page component's mount/unmount | Contextual actions for the current view (issue details, settings pages) |
| `global` | Last                     | Always present for any org                 | Org-wide navigation, create actions, help                               |

Wrap page-level actions in the slot provider:

```tsx
// In a page component or its sub-tree
<CommandPaletteSlot name="page">
  <CMDKAction display={{label: t('Resolve Issue')}} onAction={handleResolve} />
</CommandPaletteSlot>
```

Global actions are registered once in `GlobalCommandPaletteActions` — add to that component rather than creating a new global slot consumer.

---

## `CMDKAction` Props

```ts
interface CMDKActionProps {
  // Required: what the user sees
  display: {
    label: string; // primary text
    details?: string; // secondary description line
    icon?: React.ReactNode; // icon on the left — use default size for section icons,
    // size={16} for avatars (ProjectAvatar, ActorAvatar, TeamAvatar)
    trailingItem?: React.ReactNode; // right-side decoration (overrides link indicator)
  };

  // Optional: improve search recall
  keywords?: string[];

  // Optional stable key. Prefix with "cmdk:supplementary:" to sort last in
  // search results regardless of fuzzy score (used for the Help section).
  id?: string;

  // --- Choose one action type (TypeScript union enforces mutual exclusivity) ---

  // 1. Navigate
  to?: LocationDescriptor;

  // 2. Callback
  onAction?: () => void;

  // 3. Group/resource — requires children or resource to render anything.
  //    Without at least one of those the component returns null.
  resource?: (query: string, context: CMDKResourceContext) => CMDKQueryOptions;
  children?: React.ReactNode | ((data: CommandPaletteAction[]) => React.ReactNode);

  // --- Group display ---

  // Overrides the input placeholder when the user drills into this action.
  // Has no effect without children or resource — the node still needs content
  // to drill into.
  prompt?: string;

  // Max results shown before a "See all" expansion item appears.
  // Default: 4 when resource is set and children is a render-prop function.
  // No default for static children.
  limit?: number;
}
```

---

## Action Patterns

### 1. Navigation link

```tsx
import {CMDKAction} from 'sentry/components/commandPalette/ui/cmdk';
import {IconIssues} from 'sentry/icons';

<CMDKAction
  display={{
    label: t('Go to Issues'),
    icon: <IconIssues />,
  }}
  keywords={['bugs', 'errors', 'problems']}
  to={`/organizations/${org.slug}/issues/`}
/>;
```

### 2. Callback action

```tsx
<CMDKAction
  display={{label: t('Resolve Issue'), details: t('Mark as resolved')}}
  onAction={() => handleResolve(group.id)}
/>
```

### 3. Static group

Nest `CMDKAction` children to create a drillable group. The parent label appears as a breadcrumb prefix in search results (e.g. `Set Priority > High`), so use a label that identifies the context.

**Group icon as current-state indicator**: set the group's own icon to reflect the current value so the user can see the state before drilling in. Both the priority and assignee selectors do this:

```tsx
// Icon reflects current priority — user sees state at a glance
<CMDKAction
  display={{
    label: t('Set Priority'),
    icon: <IconCellSignal bars={PRIORITY_BARS[group.priority ?? PriorityLevel.MEDIUM]} />,
  }}
>
  <CMDKAction
    display={{label: t('High'), icon: <IconCellSignal bars={3} />}}
    onAction={() => setPriority('high')}
  />
  <CMDKAction
    display={{label: t('Medium'), icon: <IconCellSignal bars={2} />}}
    onAction={() => setPriority('medium')}
  />
  <CMDKAction
    display={{label: t('Low'), icon: <IconCellSignal bars={1} />}}
    onAction={() => setPriority('low')}
  />
</CMDKAction>;

// Icon reflects current assignee — avatar when assigned, generic icon when not
const assigneeIcon = group.assignedTo ? (
  <ActorAvatar actor={group.assignedTo} size={16} hasTooltip={false} />
) : (
  <IconUser />
);

<CMDKAction display={{label: t('Assign to'), icon: assigneeIcon}}>
  {/* children */}
</CMDKAction>;
```

### 4. Async resource picker

Use `resource` + `cmdkQueryOptions` to load items from an API. The user types to filter. The loading spinner activates automatically while the query is in flight.

Note: when the user drills into a resource node the palette clears the query. Your `resource` function will initially receive an empty string — design your query params accordingly.

```tsx
import {cmdkQueryOptions} from 'sentry/components/commandPalette/types';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {ProjectAvatar} from '@sentry/scraps/avatar';

<CMDKAction
  display={{label: t('Switch Project')}}
  prompt={t('Select a project...')}
  limit={5}
  resource={(query, context) =>
    cmdkQueryOptions({
      ...apiOptions.as<Project[]>()('/organizations/$organizationIdOrSlug/projects/', {
        path: {organizationIdOrSlug: org.slug},
        query: {query, per_page: 20},
        staleTime: 30_000,
      }),
      // Only fetch once the user has drilled into this node
      enabled: context.state === 'selected',
      select: projects =>
        projects.map(project => ({
          display: {
            label: project.slug,
            icon: <ProjectAvatar project={project} size={16} />,
          },
          to: `/organizations/${org.slug}/projects/${project.slug}/`,
        })),
    })
  }
/>;
```

**Rules for `resource`:**

- **Always** wrap with `cmdkQueryOptions(...)` — this injects `meta: { cmdk: true }` so the palette's loading spinner tracks the request via `useIsFetching`.
- Use `enabled: context.state === 'selected'` to defer fetching until the user actually drills in.
- The `select` field must transform the API response into `CommandPaletteAction[]`.
- `query` is the live search input value (not debounced) — pass it through as a search param.
- Use `staleTime: Infinity` for data that rarely changes (project lists, settings nav items). Use `staleTime: 30_000` for user/session data.

### 5. Resource with render-prop children

Use a render-prop when you need custom rendering or want to mix static and async items.

`CommandPaletteAction` is a union that includes groups (which have `actions`, not `children`). Don't blindly spread items into `CMDKAction` — type-narrow to only handle `to` and `onAction` variants, as the codebase's own `renderAsyncResult` helper does:

```tsx
// Type-safe helper — skip groups, which can't be spread into CMDKAction
function renderAsyncResult(item: CommandPaletteAction, index: number) {
  if ('to' in item) return <CMDKAction key={index} {...item} />;
  if ('onAction' in item) return <CMDKAction key={index} {...item} />;
  return null;
}

<CMDKAction
  display={{label: t('Assign to')}}
  prompt={t('Search teammates...')}
  resource={(query, context) =>
    cmdkQueryOptions({
      queryKey: ['members', org.slug, query],
      queryFn: () => fetchMembers(org.slug, query),
      enabled: context.state === 'selected',
      select: members =>
        members.map(m => ({
          display: {label: m.name, details: m.email},
          onAction: () => assignTo(m.id),
        })),
    })
  }
>
  {members => (
    <>
      {/* Static first entry */}
      <CMDKAction display={{label: t('Assign to me')}} onAction={assignToMe} />
      {/* Dynamic entries from resource */}
      {members.map(renderAsyncResult)}
    </>
  )}
</CMDKAction>;
```

**Auto-render limitation**: when `children` is _not_ a render-prop (static children + `resource`), resource results that are `CommandPaletteActionGroup` items are silently skipped. Only `to` and `onAction` results are auto-rendered. Use the render-prop pattern if you need groups from a resource.

### 6. Static async children via hook

When a dataset is small and already cached, fetch it with a hook and render it as static JSX children. The palette's built-in fuzzy search handles filtering client-side — no `resource` prop needed:

```tsx
// useProjectMembers fetches once and caches; palette fuzzy-searches the results
const {data: members = []} = useProjectMembers(project.id);
const assignableUsers = members.filter(m => m.id !== currentUser.id);

<CMDKAction display={{label: t('Assign to'), icon: assigneeIcon}}>
  <CMDKAction
    display={{label: t('Assign to me')}}
    onAction={() => handleAssign(currentUser)}
  />
  {assignableUsers.map(member => (
    <CMDKAction
      key={`member-${member.id}`}
      display={{
        label: member.name || member.email,
        icon: (
          <ActorAvatar
            actor={{id: member.id, name: member.name, type: 'user'}}
            size={16}
            hasTooltip={false}
          />
        ),
      }}
      onAction={() => handleAssign(member)}
    />
  ))}
  {teams.map(team => (
    <CMDKAction
      key={`team-${team.id}`}
      display={{
        label: `#${team.slug}`,
        icon: <TeamAvatar team={team} size={16} hasTooltip={false} />,
      }}
      onAction={() => handleAssign(team)}
    />
  ))}
</CMDKAction>;
```

**When to use static children vs `resource`:**

|               | Static children via hook   | `resource` prop         |
| ------------- | -------------------------- | ----------------------- |
| Dataset size  | Small, bounded             | Large or unbounded      |
| Filtering     | Client-side fuzzy search   | Server-side search      |
| Fetch timing  | Eager (on component mount) | Deferred (on drill-in)  |
| Query updates | Fixed at render            | Responds to typed query |

**Key naming for mixed entity lists**: prefix keys with the entity type to prevent collisions — `member-${id}`, `team-${id}`, `${owner.type}-${owner.id}`, `coding-agent:${id}`.

### 7. `trailingItem` — marking the active item

Use `trailingItem` with a `"Current"` badge only for **entity selections** where the user is switching between distinct objects — projects, organizations, users, environments, and similar. The badge answers the question "which one am I on right now?" when the items are otherwise indistinguishable.

**Do not** use `"Current"` for settings or modes (sort order, theme, display density, etc.). Those have a single correct value at any time, and the group's own label or icon already reflects it (see the group-icon-as-state-indicator pattern above). A `"Current"` badge on a settings option duplicates information without adding clarity.

```tsx
import {Tag} from '@sentry/scraps/badge';

// ✅ Entity selection — badge makes sense: user sees which project they're on
<CMDKAction
  display={{label: t('Switch Project')}}
  prompt={t('Select a project...')}
  resource={(query, context) => cmdkQueryOptions({...})}
>
  {/* Current project rendered statically so it always appears first */}
  <CMDKAction
    display={{
      label: currentProject.slug,
      icon: <ProjectAvatar project={currentProject} size={16} />,
      trailingItem: <Tag variant="muted">{t('Current')}</Tag>,
    }}
    to={`/organizations/${org.slug}/projects/${currentProject.slug}/`}
  />
</CMDKAction>

// ❌ Settings/mode — do not use a badge; the group label already shows the active value
<CMDKAction
  display={{
    label: t('Sort by: %s', getSortLabel(sort)), // label reflects current state
    icon: <IconSort />,
  }}
>
  {sortKeys.map(key => (
    <CMDKAction
      key={key}
      display={{
        label: getSortLabel(key),
        // trailingItem: key === sort ? <Tag variant="muted">{t('Current')}</Tag> : undefined
        // ❌ Don't do this — the group label already communicates the active sort
      }}
      onAction={() => onSortChange(key)}
    />
  ))}
</CMDKAction>
```

### 7. Query-content-gated resource

A resource can activate based on what the user has typed, not just drill-in state. Use this for contextual lookup tools that only make sense for a specific query shape:

```tsx
const DSN_PATTERN = /^https?:\/\/.+@.+\/.+/;

<CMDKAction
  display={{label: t('DSN Lookup')}}
  prompt={t('Paste a DSN...')}
  resource={(query, context) =>
    cmdkQueryOptions({
      ...apiOptions.as<DsnLookupResponse>()(/* ... */),
      // Only fetch when the query looks like a DSN
      enabled: context.state === 'selected' && DSN_PATTERN.test(query),
      select: result => result.navTargets.map(/* ... */),
    })
  }
/>;
```

### 8. State-conditional actions

Render different actions based on current entity state — not just feature flags. Actions that don't apply to the current state should simply not render:

```tsx
<CommandPaletteSlot name="page">
  <CMDKAction display={{label: issueTitle}} icon={<ProjectAvatar ... />}>
    {!isResolved && !isArchived && (
      <CMDKAction display={{label: t('Resolve')}} onAction={handleResolve} />
    )}
    {!isResolved && !isArchived && (
      <CMDKAction display={{label: t('Archive')}} onAction={handleArchive} />
    )}
    {isResolved && (
      <CMDKAction display={{label: t('Unresolve')}} onAction={handleUnresolve} />
    )}
    {isArchived && (
      <CMDKAction display={{label: t('Unarchive')}} onAction={handleUnarchive} />
    )}
  </CMDKAction>
</CommandPaletteSlot>
```

### 9. Supplementary (always-last) section

Prefix `id` with `cmdk:supplementary:` to sort the section after all other results, regardless of search score. Reserved for content like Help links that should never surface above real actions.

```tsx
<CMDKAction
  id="cmdk:supplementary:help"
  display={{label: t('Help')}}
  resource={helpResource}
/>
```

---

## Splitting Actions Across Components

When a page's action set is complex, split it across multiple components. Child components that register actions **do not need their own slot** — they inherit the slot context from the parent that established it. Just emit `<CMDKAction>` nodes directly:

```tsx
// views/issueDetails/groupPriorityActions.tsx
// No slot here — registers under whatever parent mounts this
function GroupPriorityActions({group}: {group: Group}) {
  return (
    <CMDKAction display={{label: t('Set Priority')}}>
      <CMDKAction display={{label: t('High')}} onAction={() => setPriority('high')} />
      <CMDKAction display={{label: t('Medium')}} onAction={() => setPriority('medium')} />
      <CMDKAction display={{label: t('Low')}} onAction={() => setPriority('low')} />
    </CMDKAction>
  );
}

// views/issueDetails/seerActions.tsx
// Returns a Fragment of siblings — adds actions into the parent group without
// creating an extra nesting level
function SeerActions({group}: {group: Group}) {
  if (!canShowSeer) return null;
  return (
    <Fragment>
      <CMDKAction
        display={{label: t('Fix with Seer'), icon: <IconSeer />}}
        onAction={startAutofix}
      />
    </Fragment>
  );
}

// views/issueDetails/issueCommandPaletteActions.tsx
// Only this component owns the slot
function IssueCommandPaletteActions({group, issue}: Props) {
  return (
    <CommandPaletteSlot name="page">
      <CMDKAction
        display={{
          label: issue.title,
          icon: <ProjectAvatar project={project} size={16} />,
        }}
      >
        <GroupPriorityActions group={group} />
        <SeerActions group={group} />
      </CMDKAction>
    </CommandPaletteSlot>
  );
}
```

Use `<Fragment>` (not a wrapping `<CMDKAction>`) when a child component contributes flat siblings into an existing parent group.

---

## Registering Global Actions

Add to `GlobalCommandPaletteActions` in `commandPaletteGlobalActions.tsx`. Don't create a second `global` slot consumer — there is one slot outlet in the navigation shell, so a second consumer would compete with it rather than extend it. It's a JSX component — just insert a new `CMDKAction` in the relevant group or create a new named group:

```tsx
// Inside GlobalCommandPaletteActions render:
<CMDKAction display={{label: t('Go to')}}>
  {/* existing actions... */}
  <CMDKAction
    display={{label: t('Monitors'), icon: <IconTimer />}}
    to={`/organizations/${organization.slug}/crons/`}
  />
</CMDKAction>
```

---

## Registering Page-Level Actions

Create a component that wraps actions in `<CommandPaletteSlot name="page">` and mount it inside the relevant page component. The actions register and deregister automatically with the page's mount/unmount lifecycle.

```tsx
// views/myFeature/myFeatureCommandPaletteActions.tsx
function MyFeatureCommandPaletteActions({item}: {item: MyItem}) {
  return (
    <CommandPaletteSlot name="page">
      <CMDKAction
        display={{label: t('Archive'), details: item.name}}
        onAction={() => archiveItem(item.id)}
      />
    </CommandPaletteSlot>
  );
}

// views/myFeature/myFeaturePage.tsx
function MyFeaturePage() {
  return (
    <div>
      <MyFeatureCommandPaletteActions item={item} />
      {/* rest of page */}
    </div>
  );
}
```

---

## Feature Flag and Permission Gates

Gate on additional flags or permissions inline:

```tsx
{
  organization.features.includes('my-feature') && (
    <CMDKAction display={{label: t('My New Action')}} onAction={doThing} />
  );
}

{
  user.isStaff && <CMDKAction display={{label: t('Admin Panel')}} to="/admin/" />;
}
```

**Gate the entire slot when a page is disabled** — don't render individual disabled actions; don't render the slot at all:

```tsx
// ✅ Gate at the slot level
{
  !disabled && (
    <CommandPaletteSlot name="page">
      <CMDKAction display={{label: entity.title}}>{/* all actions */}</CMDKAction>
    </CommandPaletteSlot>
  );
}
```

---

## Capability Config

When an entity type determines which actions are available, derive that from a config object rather than inline conditionals. `getConfigForIssueType(group, project)` returns per-action capability flags:

```tsx
const config = useMemo(() => getConfigForIssueType(group, project), [group, project]);
const {
  actions: {resolve: resolveCap, delete: deleteCap},
} = config;

// Only render actions the issue type supports
{
  resolveCap.enabled && (
    <CMDKAction display={{label: t('Resolve')}} onAction={handleResolve} />
  );
}
```

For new entity types, follow the same pattern: define a config shape that carries capability flags, then gate rendering on those flags rather than scattered `group.type === '...'` checks.

---

## Workflow / Sequential State Machine

When actions represent steps in a multi-stage workflow, show only the _next valid action_ — not all possible steps at once. Gate each step on the previous step being complete and the next not yet started:

```tsx
// Extract state into a dedicated hook in the same file
function useSeerState(group: Group, project: Project) {
  const autofix = useExplorerAutofix(group.id);
  const sections = getOrderedAutofixSections(autofix.runState);

  return {
    autofix,
    completedRootCause: sections.some(
      s => isRootCauseSection(s) && s.status === 'completed'
    ),
    completedSolution: sections.some(
      s => isSolutionSection(s) && s.status === 'completed'
    ),
    completedCodeChanges: sections.some(
      s => isCodeChangesSection(s) && s.status === 'completed'
    ),
    hasPR: sections.some(isPullRequestsSection),
    runId: autofix.runState?.run_id,
    isPolling: autofix.isPolling,
  };
}

function WorkflowActions({group, project}: Props) {
  const {
    autofix,
    completedRootCause,
    completedSolution,
    completedCodeChanges,
    hasPR,
    runId,
    isPolling,
  } = useSeerState(group, project);

  // Guard: can only advance the workflow when not mid-operation and run exists
  const canContinue = !isPolling && defined(runId);

  return (
    <Fragment>
      {(!autofix.runState || autofix.runState.status === 'error') && (
        <CMDKAction display={{label: t('Fix with Seer')}} onAction={startFix} />
      )}
      {canContinue && completedRootCause && !completedSolution && (
        <CMDKAction
          display={{label: t('Generate solution')}}
          onAction={() => nextStep('solution', runId)}
        />
      )}
      {canContinue && completedSolution && !completedCodeChanges && (
        <CMDKAction
          display={{label: t('Generate code changes')}}
          onAction={() => nextStep('code_changes', runId)}
        />
      )}
      {canContinue && completedCodeChanges && !hasPR && (
        <CMDKAction
          display={{label: t('Open pull request')}}
          onAction={() => createPR(runId)}
        />
      )}
    </Fragment>
  );
}
```

Key points:

- Extract the state logic into a dedicated `use*State` hook within the action component file — keeps the JSX clean.
- Use a `canContinue` guard to prevent showing progress actions while an async operation is in flight.
- Return `null` early at the top of the component when the feature isn't applicable:

```tsx
// Guard clause — return null before any hooks if possible, else after
if (!aiConfig.areAiFeaturesAllowed || !isExplorer || !issueTypeSupportsSeer || !event) {
  return null;
}
```

---

## Dynamic Labels

Embed the current value in an action label to give context without requiring the user to drill in first:

```tsx
// Shows who is currently assigned
<CMDKAction
  display={{label: t('Unassign from %s', currentAssigneeName)}}
  onAction={() => handleAssigneeChange(null)}
/>

// Shows the current value being changed
<CMDKAction
  display={{label: t('Change theme: %s', currentTheme)}}
  onAction={openThemePicker}
/>
```

Use `t('... %s', value)` (printf-style) rather than template literals so strings remain translatable.

---

## Checklist

- [ ] Wrap page-level actions in `<CommandPaletteSlot name="page">`; add global actions to `GlobalCommandPaletteActions`
- [ ] Child components that split a page action set do **not** add their own slot — they inherit from the parent
- [ ] All `resource` functions use `cmdkQueryOptions(...)`
- [ ] `resource` functions set `enabled: context.state === 'selected'` to defer fetching (or a query-content check for contextual resources)
- [ ] `select` in resource options returns `CommandPaletteAction[]`
- [ ] `prompt` is set on any drill-target that replaces the search placeholder
- [ ] `limit` is set on resource nodes to avoid overwhelming the list (default 4 only applies when `resource` AND `children` is a render-prop function; auto-render mode has no default)
- [ ] `staleTime: Infinity` for stable lists (projects, nav items); `staleTime: 30_000` for dynamic data
- [ ] `id="cmdk:supplementary:..."` on any section that should always sort last
- [ ] `keywords` added for non-obvious actions to improve search recall
- [ ] Section/group icons use default size; avatar icons (`ProjectAvatar`, `ActorAvatar`, `TeamAvatar`) use `size={16}`
- [ ] State-conditional actions (resolved, archived, etc.) are rendered conditionally rather than disabled
- [ ] `disabled` state gates the entire `<CommandPaletteSlot>`, not individual actions
- [ ] Group icon reflects the current value of the setting it controls (priority, assignee, theme)
- [ ] Dynamic action labels use `t('... %s', value)` not template literals, so strings stay translatable
- [ ] Workflow action components extract state logic into a dedicated `use*State` hook and use a `canContinue` guard
- [ ] Components that are not applicable return `null` early via a guard clause before rendering any JSX
- [ ] Entity capability config (e.g. `getConfigForIssueType`) drives action availability rather than scattered type checks
- [ ] Dynamic list keys use `type-id` format (`member-${id}`, `team-${id}`) to prevent cross-type collisions

