How-to › Ship a CLI or REPL

How to add a keyboard command bar over your API operations#

Build a command palette over every operation in an OpenAPI document, opening in the sandbox and refusing a delete until its operationId is typed back.

Audience
Platform team
Level
advanced
Topic
Explore an API interactively
Languages
TypeScript and JavaScript
Verified

A support engineer needs to void one invoice. The CLI can do it, in a command they run twice a year and never remember, so they open a ticket for your team instead. Every operation they need is already named in the OpenAPI document, but the internal tool gives them no way to reach one without reading code.

What you get

You will end up with a command palette over every operation in your OpenAPI document, opening in the sandbox and refusing a delete until its name is typed back. This is for you if support staff ask your team to run operations a CLI exposes.

Short answer

Build the item list from the OpenAPI document at build time, one item per operationId with the summary, tags, method, and path as search keywords. Render it with cmdk, with kbar, or in a plain dialog element over a filtered list. Open the palette in the sandbox, badge every write, and refuse to run a delete until the user types its operationId back.

You will need

An OpenAPI 3 document in which every operation carries an operationId, a web app you can add a component to, and Node 22 or later for the build step. Verified 2026-10-21 against Node 22.22.0, cmdk 1.1.1, kbar 1.0.0, fuse.js 7.5.0, react 19.3.1, and esbuild 0.28.2. The sample builds the list, drives the guard as a state machine, and measures both libraries from Node with the Node test runner. cmdk and kbar are React components. The baseline is the dialog element, which needs no dependency.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A plain HTML dialog element with a filtered listNo React, or a list short enough that plain substring matching finds thingsFocus handling, the ARIA wiring, and any ranking are yours to write and to keep working in every browserUsers type initials and typos and expect a match
cmdkA React app that wants ranked matching and the combobox roles wired, in about 50 KBRadix Dialog in the bundle, a ranking that puts listMeterReadings under del, and styling from zeroYou need sections ranked by priority or actions registered from deep in the tree
kbarA React app with many actions, nested actions, sections, and shortcuts registered from anywhere in the treeAbout twice the bytes of cmdk, through Fuse.js and list virtualization, and a matcher that returns nothing for the initials lmA short list, where a 110 KB palette is most of the page

The two libraries ship the same shape: an input with role="combobox", a listbox, and option rows. cmdk ranks by subsequence, so lm finds listMeters but del also drags in listMeterReadings. kbar ranks with Fuse.js, which forgives the typo metre because it scores edit distance, so the initials lm return nothing. The plain dialog matches each word of the query as a substring, in any order, which is enough until the list outgrows what people can read.

Build the item list once, from the document

Everything the palette shows comes from the OpenAPI document, so build the list in the step that builds the app, never at runtime in the browser. One item per operation: the operationId is the id, the summary is the text, and the tags, the method, and the path segments become keywords.

export function buildItems(doc) {
  const items = []
  for (const [path, ops] of Object.entries(doc.paths)) {
    for (const method of METHODS) {
      const op = ops[method]
      if (!op) continue
      if (!op.operationId) throw new Error(`${method.toUpperCase()} ${path} has no operationId`)
      items.push({
        id: op.operationId,
        label: op.summary ?? op.operationId,
        method: method.toUpperCase(),
        path,
        keywords: [...(op.tags ?? []), method, ...path.split('/').filter((s) => s && !s.startsWith('{'))],
        mutating: WRITES.has(method),
        // The method says DELETE. It does not say that a POST to /purge destroys a tenant,
        // so the spec gets to say that too.
        destructive: method === 'delete' || op['x-destructive'] === true,
        deprecated: Boolean(op.deprecated),
      })
    }
  }
  return items
}

The operationId is required rather than defaulted. An operation without one has no stable name for a palette, a CLI, or a generated client, and the OpenAPI Specification requires the ids a document does carry to be unique.2 A missing id fails the build rather than the palette.

node doc.mjs
listMeters         GET     /meters                           READ
createMeter        POST    /meters                           WRITE
getMeter           GET     /meters/{meterId}                 READ
updateMeter        PATCH   /meters/{meterId}                 WRITE
deleteMeter        DELETE  /meters/{meterId}                 DELETE
listMeterReadings  GET     /meters/{meterId}/readings        READ
recordReading      POST    /meters/{meterId}/readings        WRITE
listInvoices       GET     /invoices                         READ
voidInvoice        POST    /invoices/{invoiceId}/void        WRITE
purgeTenant        POST    /admin/tenants/{tenantId}/purge   DELETE
10 operations, 6 writes, 2 destructive

Two of the ten carry the DELETE badge. One earns it from the HTTP method. The other is purgeTenant, a POST that destroys a tenant, marked x-destructive in the document because the method alone would call it a write.

Measure the two libraries before choosing one

Run the same five queries through each ranking. cmdk exports defaultFilter, the function its Command component ranks with. kbar ranks inside a React hook with Fuse.js, so the script runs Fuse with the options copied from kbar’s installed source, including the token search that lets a query span fields.

// kbar ranks with Fuse.js inside its useMatches hook, which needs a React render to call.
// These are the options the hook passes, copied from the installed package, so this is
// kbar's ranking without the component around it.
const KBAR_FUSE_OPTIONS = {
  keys: [
    { name: 'name', weight: 0.5 },
    { name: 'keywords', getFn: (a) => (a.keywords ?? '').split(','), weight: 0.3 },
    { name: 'subtitle', weight: 0.2 },
  ],
  ignoreLocation: true,
  ignoreDiacritics: true,
  includeScore: true,
  threshold: 0.2,
  minMatchCharLength: 1,
  useTokenSearch: true,
  tokenMatch: 'all',
}
const actions = items.map((it) => ({ id: it.id, name: it.label, keywords: keywordsOf(it).join(','), subtitle: `${it.method} ${it.path}` }))
const fuse = new Fuse(actions, KBAR_FUSE_OPTIONS)

export const rankKbar = (query) => fuse.search(query).map((r) => r.item.id)
node rank.mjs
"del"
  cmdk   deleteMeter listMeterReadings
  kbar   deleteMeter
  plain  deleteMeter
"void invoice"
  cmdk   voidInvoice
  kbar   voidInvoice
  plain  voidInvoice
"lm"
  cmdk   listMeters listMeterReadings deleteMeter purgeTenant
  kbar   (nothing)
  plain  (nothing)
"metre"
  cmdk   listMeterReadings recordReading createMeter listMeters getMeter updateMeter deleteMeter purgeTenant
  kbar   listMeters createMeter getMeter updateMeter deleteMeter listMeterReadings recordReading
  plain  (nothing)
"readings post"
  cmdk   recordReading
  kbar   recordReading
  plain  recordReading

Read the five blocks as a choice of failure. A prefix, del, and two words in the wrong order, void invoice, work everywhere. Initials, lm, work in cmdk alone, because it matches a subsequence, and that same rule is why del also ranks listMeterReadings. The typo metre works in both libraries, since Fuse scores edit distance3 and a subsequence of metre exists in most labels. The plain filter finds nothing for either.

Bundle weight comes from the esbuild API, with React left out of every figure.

node size.mjs
cmdk             52374 bytes minified, React excluded
kbar            113842 bytes minified, React excluded
fuse.js alone    26530 bytes minified, React excluded
dialog.mjs        2077 bytes minified, React excluded

kbar brings Fuse.js and list virtualization, which is most of the gap. The last row is the baseline, dialog.mjs with palette.mjs behind it.

The roles come from the markup each library renders with renderToStaticMarkup.

node aria.mjs
cmdk
  input   aria-autocomplete role="combobox" aria-expanded aria-controls aria-labelledby
  list    role="listbox" aria-label
  options 10 rendered with role="option"
kbar
  input   role="combobox" aria-expanded aria-controls aria-activedescendant
  list    role="listbox"
  options 0 rendered with role="option"

Both render the combobox pattern: an input with role="combobox" that keeps focus, a listbox, and option rows.4 kbar renders no rows on the server because its list is virtualized and measures itself in the browser, so its role="option" and aria-selected per row come from its source rather than from this output. The baseline gets the same roles only if you type them, which palette.html does.

Open in the sandbox and make a delete cost a word

The palette’s cost is the same thing as its value: every operation is two keystrokes away, including the ones that destroy data. The state machine that decides what Enter does is the part to test, and it has no DOM in it.

export function decide(item, { env, typed = '' }) {
  if (!item) return { run: false, reason: 'nothing selected' }
  const needsTyping = item.destructive || (env !== 'sandbox' && item.mutating)
  if (needsTyping && typed !== item.id) {
    return { run: false, confirm: true, reason: `type ${item.id} to confirm` }
  }
  return { run: true }
}

export function enter(state, run) {
  const item = state.confirm?.item ?? visible(state)[state.index]
  const verdict = decide(item, { env: state.env, typed: state.confirm?.typed ?? '' })
  if (verdict.run) {
    run(item, state.env)
    return { ...state, open: false, confirm: null, notice: `ran ${item.id} against ${state.env}` }
  }
  const confirm = verdict.confirm ? { item, typed: '' } : null
  return { ...state, confirm, notice: verdict.reason }
}

Three rules, in order of how often they fire. The palette opens against the server the document describes as sandbox, and production is a switch the user flips on purpose. A destructive operation asks for its own id typed back, in every environment, and a near miss such as deletemeter is not a confirmation. Outside the sandbox, every write asks the same question, while a read runs on Enter alone.

The check compares with sandbox, not production, so a server the document calls Production or staging is guarded too.

The browser side is thin. The input inside the dialog takes the keystrokes, and showModal() puts the dialog in the top layer with the rest of the page inert behind it.

  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') {
      state = enter(state, run)
      input.value = ''
    } else {
      state = press(state, event.key)
    }
    render()
  })
  document.addEventListener('keydown', (event) => {
    if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
      event.preventDefault()
      state = { ...openPalette(items, { env: state.env }) }
      dialog.showModal()
      input.focus()
      render()
    }
  })
<dialog id="ops" data-env="sandbox">
  <input type="text" role="combobox" aria-controls="ops-list" aria-expanded="true" autocomplete="off">
  <ul id="ops-list" role="listbox"></ul>
</dialog>

The data-env attribute is the default the palette opens with. Production appears nowhere in the markup, because switching to it is something the user does on purpose in the UI.

Check it worked

Run the tests. They assert on what ran, not on what the palette displayed.

node --test palette.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 99.425418

# pass 8 covers the three that matter: deleteMeter does not run on Enter, does not run on deletemeter, and runs on deleteMeter. A fourth asserts that purgeTenant, a POST, needs the same word. A fifth asserts that a write asks for its id under every environment name but sandbox.

When it goes wrong

Enter deletes a record in production. The palette was built the obvious way, pointed at the first server in the document and running whatever row was selected. Both palettes below receive the same keystrokes.

node pitfall.mjs
unguarded palette, pointed at production
  typed "del", pressed Enter
  ran deleteMeter: DELETE https://api.meterco.example/v1/meters/{meterId}
palette.mjs
  typed "del", pressed Enter
  type deleteMeter to confirm (environment sandbox)
  typed "deleteMeter", pressed Enter
  ran deleteMeter against sandbox: DELETE https://sandbox.meterco.example/v1/meters/{meterId}
2 requests sent, the second one to the sandbox

The first palette sent a DELETE to production on four keystrokes. The second asked for twelve more, the eleven letters of deleteMeter and Enter, and sent it to the sandbox.

A destructive POST wears the WRITE badge. The item builder had only the HTTP method to go on, so purgeTenant looked like any other write until the document said otherwise. OpenAPI has no standard flag for this, so the sample reads an x-destructive extension; MCP’s destructiveHint is the same idea in another protocol. Mark the operation in the document, where the next tool reads it too.

Initials find nothing. kbar’s token matcher and the plain filter both return nothing for lm, because neither matches a subsequence. Add the aliases people use to the keywords, lm beside listMeters, rather than changing libraries for one query shape.

When not to do this

Do not build a palette for an app with eight operations. A menu with eight entries is faster to learn than a search box with eight answers.

Do not treat the typed confirmation as authorization. It stops a slip of the hand. It does nothing against a person who means to delete the record, and the API’s own permissions are where that decision belongs. A palette user should hold a token that can do only what the sandbox allows until they switch environments on purpose.

Do not expose a customer-facing API this way. In front of customers the palette is a second UI to keep consistent with the first.

Do not ship kbar or cmdk into a page without React. The baseline dialog is two kilobytes.

Do not skip the build step and read the OpenAPI document in the browser. The list changes when the document changes, which is a deploy, and a palette that fetches its own menu at runtime shows an empty list when that fetch fails.

Last verified

Verified 2026-10-21 against Node 22.22.0, cmdk 1.1.1, kbar 1.0.0, fuse.js 7.5.0, react 19.3.1, react-dom 19.3.1, and esbuild 0.28.2. Every output block is what the command preceding it printed. The two libraries were measured from Node: their ranking functions called directly, their markup rendered with renderToStaticMarkup, and their bundles built with esbuild. kbar’s ranking was reproduced with Fuse.js and the options its useMatches hook passes, read from the installed package. No browser was driven, so each library’s keyboard handling was read from its source, and dialog.mjs and palette.html were not executed.

Footnotes

  1. showModal() is the half of the element that earns the baseline its place. MDN describes it as showing the dialog as a modal, over the top of anything else that might be visible. The HTML Standard defines the method’s steps as showing a modal dialog, in a section that also specifies the ::backdrop and the inertness of everything outside it. Trapping focus, the part every palette library once wrote by hand, is the browser’s job in that sentence. ↩︎ Back to text

  2. The specification’s paragraph on operationId uses three verbs of different strength. The id MUST be unique among all operations described in the API, and the value is case-sensitive. Following common programming naming conventions is RECOMMENDED, since tools MAY use the id to identify an operation. A palette is one of those tools. The Operation Object says all of this in four sentences. ↩︎ Back to text

  3. Fuse scores a match between 0, a perfect match, and 1, which matches anything, and cuts off at threshold, 0.6 by default according to its fuzzy search page. kbar passes 0.2 with ignoreLocation: true, so a match may sit anywhere in the field and has to be close. metre against meter is one letter out of place, which is close enough. lm against List meters is two letters with a word between them, which is no pattern at all. ↩︎ Back to text

  4. The combobox pattern keeps DOM focus on the text box while assistive technology focus moves through the list with aria-activedescendant, as the Authoring Practices Guide puts it. In that server-rendered markup only kbar’s input carries the attribute. cmdk’s source sets it in the browser, to the id of the row it marks with aria-selected, and on the server no row is selected yet: all ten render aria-selected="false". ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.