Skip to content
Back to skills

Cmdk Actions

ASecurity

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.

  • 44,888 stars
  • 0 votes
  • 0 copies
  • 5 views
  • Added June 12, 2026
devopstypescriptgoshellreactnodegitapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned June 12, 2026

npx -y skills add getsentry/sentry --skill cmdk-actions --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Cmdk Actions?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Cmdk Actions
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/getsentry-cmdk-actions/badge)](https://www.skillsdirectory.com/skills/getsentry-cmdk-actions)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: cmdk-actions
description: 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.
---

# 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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.