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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A plain HTML dialog element with a filtered list | No React, or a list short enough that plain substring matching finds things | Focus handling, the ARIA wiring, and any ranking are yours to write and to keep working in every browser | Users type initials and typos and expect a match |
| cmdk | A React app that wants ranked matching and the combobox roles wired, in about 50 KB | Radix Dialog in the bundle, a ranking that puts listMeterReadings under del, and styling from zero | You need sections ranked by priority or actions registered from deep in the tree |
| kbar | A React app with many actions, nested actions, sections, and shortcuts registered from anywhere in the tree | About twice the bytes of cmdk, through Fuse.js and list virtualization, and a matcher that returns nothing for the initials lm | A 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.
Related how-tos
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
-
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::backdropand 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 -
The specification’s paragraph on
operationIduses 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 -
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 withignoreLocation: true, so a match may sit anywhere in the field and has to be close.metreagainstmeteris one letter out of place, which is close enough.lmagainstList metersis two letters with a word between them, which is no pattern at all. ↩︎ Back to text -
The
comboboxpattern keeps DOM focus on the text box while assistive technology focus moves through the list witharia-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 witharia-selected, and on the server no row is selected yet: all ten renderaria-selected="false". ↩︎ Back to text