A command palette for the agentic web. Extends cmdk with tool execution, auto-generated forms, WebMCP registration, and human-in-the-loop agent mode.
Define your tools once as JSON Schema. Users browse and execute them from a command palette. AI agents discover them via WebMCP. Both share the same interface — human-in-the-loop by design, with an opt-in approval gate.
On-page agent experiences usually mean high fidelity at the cost of a bespoke integration to maintain. agentk collapses that trade: one tool catalog gives humans a palette and agents a WebMCP surface, so the on-page fidelity comes free with the UI you were building anyway.
Live demo · npm — running in production on the Stacktree dashboard, where the same catalog serves the ⌘K palette and in-browser agents.
npm install @stevysmith/agentkimport { Command } from '@stevysmith/agentk'
const tools = [
{
name: 'search',
label: 'Search Products',
description: 'Find products by keyword',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
},
required: ['query'],
},
},
]
async function executeTool(name: string, params: Record<string, any>) {
// your app logic
}
const App = () => {
const [open, setOpen] = React.useState(false)
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
tools={tools}
onToolExecute={executeTool}
label="Command Menu"
>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Group heading="Tools">
{tools.map((tool) => (
<Command.Tool key={tool.name} tool={tool} />
))}
</Command.Group>
<Command.Empty>No results found.</Command.Empty>
</Command.List>
<Command.ToolForm />
<Command.ToolResult />
</Command.Dialog>
)
}When a user selects a tool, agentk transitions through a built-in lifecycle: browse → form → executing → result. Forms are generated automatically from inputSchema — enum fields render as dropdowns, number fields with minimum/maximum render as sliders, and string fields render as text inputs.
Add the agent prop and three more primitives to enable natural language intent → tool execution:
<Command.Dialog
open={open}
onOpenChange={setOpen}
tools={tools}
onToolExecute={executeTool}
agent={{
provider: 'anthropic',
endpoint: '/api/agent',
requireApproval: true,
}}
label="Command Menu"
>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Group heading="Tools">
{tools.map((tool) => (
<Command.Tool key={tool.name} tool={tool} />
))}
</Command.Group>
<Command.Empty>No results found.</Command.Empty>
</Command.List>
<Command.AgentHint />
<Command.Approval />
<Command.ToolForm />
<Command.ToolResult />
<Command.ActivityFeed />
</Command.Dialog>When the user types a query that doesn't match any tool, AgentHint appears prompting them to press Enter. The query is sent to the LLM, which returns a plan of tool calls. The user reviews and approves the plan in Approval before any tools execute.
All parts forward props and refs to an appropriate element. Each part has a specific data-attribute that can be used for styling.
Root component. Inherits the full cmdk API and adds the following props:
| Prop | Type | Description |
|---|---|---|
tools |
AgentKToolDef[] |
Tool definitions to register in the palette |
onToolExecute |
(name, params) => Promise<string | Record> |
Called when a tool is executed. Return a string for text display, an object for JSON. |
onToolResult |
(name, result) => void |
Called after successful execution |
onToolError |
(name, error) => void |
Called on execution failure |
onModeChange |
(mode) => void |
Called when mode changes |
agent |
AgentKAgentConfig |
LLM agent configuration (omit to disable) |
onAgentPlan |
(plan) => void |
Called when the LLM returns a plan |
onAgentApprove |
(plan) => void |
Called when user approves a plan |
onAgentReject |
(plan) => void |
Called when user rejects a plan |
All standard cmdk props (value, onValueChange, filter, shouldFilter, loop, label) are also supported.
The root element exposes data-agentk-mode reflecting the current state machine mode. Use it for mode-aware styling:
/* Hide tool list when a form, result, or execution is active */
[data-agentk-mode="form"] [cmdk-list] { display: none; }
[data-agentk-mode="executing"] [cmdk-list] { display: none; }
[data-agentk-mode="result"] [cmdk-list] { display: none; }
[data-agentk-mode="planning"] [cmdk-list] { display: none; }
[data-agentk-mode="approval"] [cmdk-list] { display: none; }Possible values: browse, form, executing, result, planning, approval. Works alongside data-agentk-entering and data-agentk-exiting for transition animations.
Composes Radix UI's Dialog. Props are forwarded to Command.
<Command.Dialog open={open} onOpenChange={setOpen}>
...
</Command.Dialog>Search input. All props forwarded to the underlying input element.
<Command.Input placeholder="Search tools..." />Contains Tool items and groups. Animate height using the --cmdk-list-height CSS variable.
[cmdk-list] {
height: var(--cmdk-list-height);
transition: height 100ms ease;
}Renders a selectable tool item. Selecting it transitions to the form view.
// Default rendering: icon + name + description
<Command.Tool tool={tool} />
// Custom rendering: children fully replace the default layout
<Command.Tool tool={tool}>
<MyCustomIcon />
<div>
<strong>{tool.label}</strong>
<p>{tool.description}</p>
</div>
</Command.Tool>When children are omitted, the default layout renders data-agentk-tool-icon, data-agentk-tool-name, and data-agentk-tool-description elements for styling. When children are provided, only your children render.
The tool prop is an AgentKToolDef:
type AgentKToolDef = {
name: string
label?: string // Falls back to humanized name
description?: string
inputSchema?: { // JSON Schema for parameters
type: 'object'
properties: Record<string, {
type: string
description?: string
enum?: string[] // → renders dropdown
minimum?: number // → renders slider (with maximum)
maximum?: number
default?: any
}>
required?: string[]
}
icon?: React.ReactNode
keywords?: string[] // Aliases for fuzzy matching
}Auto-generates a parameter form from the active tool's inputSchema. Renders when mode is form.
// Default: auto-generated fields
<Command.ToolForm />
// Custom field renderer
<Command.ToolForm
renderField={(name, schema, value, onChange) => (
<MyCustomInput value={value} onChange={onChange} />
)}
/>
// Custom action buttons (Cancel + Execute)
<Command.ToolForm
renderActions={({ cancel, submit, canSubmit }) => (
<div>
<button onClick={cancel}>Cancel</button>
<button onClick={submit} disabled={!canSubmit}>Run it</button>
</div>
)}
/>Schema type mapping:
| Schema | Rendered as |
|---|---|
type: 'string' |
Text input |
type: 'string', enum: [...] |
Select dropdown |
type: 'number', minimum, maximum |
Range slider |
type: 'number' |
Number input |
type: 'boolean' |
Checkbox |
Displays the result after tool execution. Renders when mode is result.
The return value of onToolExecute controls what is displayed:
onToolExecute={async (name, params) => {
const data = await myApi(name, params);
// String → rendered as text in <span data-agentk-result-data="">
return `Found ${data.length} results`;
// Object → rendered as formatted JSON in <pre data-agentk-result-data="">
// return { count: data.length, items: data };
}}// Default display
<Command.ToolResult />
// Custom result renderer
<Command.ToolResult
renderResult={(execution) => (
<div>
{execution.error
? <span>Error: {execution.error}</span>
: <span>Done in {((Date.now() - execution.startedAt) / 1000).toFixed(1)}s</span>
}
</div>
)}
/>
// Custom dismiss button
<Command.ToolResult
renderDismiss={({ dismiss }) => (
<button onClick={dismiss}>Got it</button>
)}
/>
// Auto-dismiss successful results after 6s
<Command.ToolResult autoDismissAfterMs={6000} />autoDismissAfterMs only fires for successful results — errors stay visible
until the user dismisses them manually. Combine with onModeChange if you need
side effects (e.g. navigation) when the panel closes.
The execution object:
type ToolExecution = {
toolName: string
parameters: Record<string, any>
result?: any
error?: string
startedAt: number
}Appears when the search query doesn't match any tool but an agent is configured. Interactive — clicking, pressing Enter, or pressing Space triggers sendIntent with the current search query. Renders with role="button" and tabIndex={0} for accessibility.
// Default: "Ask the agent" with sparkle icon
<Command.AgentHint />
// Custom content
<Command.AgentHint>
<span>Let AI handle this</span>
</Command.AgentHint>Set data-agentk-hint on [cmdk-root] is toggled automatically when the hint is visible, useful for styling the input border:
[cmdk-root][data-agentk-hint] [cmdk-input] {
border-bottom-color: var(--accent);
}Renders the agent's plan for user review before execution. Shows each proposed tool call with parameters. The user can approve or reject.
// Default display
<Command.Approval />
// Custom renderers
<Command.Approval
renderSummary={(plan) => <p>{plan.summary}</p>}
renderCall={(call, index) => (
<div>{call.toolName}({JSON.stringify(call.parameters)})</div>
)}
renderActions={({ approve, reject }) => (
<div>
<button onClick={reject}>Cancel</button>
<button onClick={approve}>Run plan</button>
</div>
)}
/>Shows a timeline of agent activity: intent detection, planning, tool execution, results.
<Command.ActivityFeed maxEntries={20} />A Command.Item that triggers sendIntent when selected, instead of the default tool-selection behaviour. Renders identically to other items — same styling, same keyboard navigation.
<Command.IntentTrigger query="summer programs in europe">
Search Europe
</Command.IntentTrigger>Must be rendered inside a Command.List. Style with [data-agentk-intent-trigger].
Note: Since IntentTrigger is a Command.Item, it counts as a matching item for filtering. If you want AgentHint to appear when the user types a custom query, place IntentTrigger items alongside other items so they get filtered out by cmdk's fuzzy matching. For agent-only search (no items), use AgentHint directly without IntentTrigger.
Renders when there are no results. Automatically hidden when AgentHint is visible.
Groups items with a heading. Same as cmdk.
<Command.Group heading="Actions">
<Command.Tool tool={tool} />
</Command.Group>Same as cmdk. See cmdk documentation.
Access agentk state from within the Command tree.
function MyComponent() {
const ak = useAgentK()
// Read state
ak.state.mode // 'browse' | 'form' | 'executing' | 'result' | 'planning' | 'approval'
ak.state.activeTool // current tool or null
ak.state.parameters // current form values
ak.state.execution // current execution or null
ak.agentHintVisible // true when agent hint is showing
// Actions
ak.selectTool(tool)
ak.setParameter('key', value)
ak.execute()
ak.reset()
ak.sendIntent('natural language query')
ak.approvePlan()
ak.rejectPlan()
}Discover tools registered by other apps on the page via WebMCP.
const { tools, available, refresh, executeTool } = useWebMCPTools()
// tools: AgentKToolDef[] — discovered tools
// available: boolean — whether WebMCP API is present
// refresh: () => void — re-scan for tools
// executeTool: (name, params) => Promise<any>Same as cmdk. Access the underlying combobox state.
The agent prop accepts:
type AgentKAgentConfig = {
provider: 'anthropic' | 'openai' | 'google' | 'custom'
apiKey?: string // For development only — warns in browser
endpoint?: string // Proxy URL for production
model?: string // Defaults: claude-sonnet-4-20250514, gpt-4o, gemini-2.0-flash
systemPrompt?: string // Override the built-in system prompt
requireApproval?: boolean // Show Approval before executing (default: false)
maxCalls?: number // Max tool calls per plan
providerFn?: AgentKProvider // Custom provider function
}For production, proxy through your own server using the endpoint prop instead of exposing API keys client-side:
agent={{
provider: 'anthropic',
endpoint: '/api/agent',
requireApproval: true,
}}agent={{
provider: 'custom',
providerFn: async (prompt, tools, config) => {
const res = await fetch('/my-api', {
method: 'POST',
body: JSON.stringify({ prompt, tools }),
})
return res.json() // { calls: AgentKToolCall[], summary: string }
},
}}agentk tools use the same JSON Schema format as WebMCP. Register them so AI agents can discover your app. Note that Chrome 150 moved the API from navigator.modelContext to document.modelContext; feature-detect both:
useEffect(() => {
const mc = document.modelContext ?? navigator.modelContext
if (!mc) return
for (const tool of tools) {
mc.registerTool({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (params) => {
const result = await executeTool(tool.name, params)
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
},
})
}
return () => {
for (const tool of tools) {
mc.unregisterTool(tool.name)
}
}
}, [])Define once, use everywhere — the same tool definitions power the palette UI, the agent, and WebMCP discovery.
For customer-facing search where users type natural language and get results — no tool browsing needed:
<Command.Dialog
open={open}
onOpenChange={setOpen}
tools={tools}
onToolExecute={handleExecute}
agent={{ provider: 'anthropic', endpoint: '/api/agent' }}
>
<Command.Input placeholder="What are you looking for?" />
<Command.List>
{/* No Command.Tool items — agent-only */}
<Command.AgentHint />
<Command.Empty>Type to search with AI</Command.Empty>
</Command.List>
<Command.ToolResult />
</Command.Dialog>Use data-agentk-mode to hide the list during execution:
[data-agentk-mode="executing"] [cmdk-list] { display: none; }
[data-agentk-mode="result"] [cmdk-list] { display: none; }Add suggested queries with IntentTrigger:
<Command.List>
<Command.Group heading="Suggestions">
<Command.IntentTrigger query="popular items">Popular items</Command.IntentTrigger>
<Command.IntentTrigger query="deals under $50">Deals under $50</Command.IntentTrigger>
</Command.Group>
<Command.AgentHint />
</Command.List>Every part exposes data-attributes you can target from CSS without bundling
styles. The cmdk-prefixed attrs come from cmdk; everything data-agentk-* is
introduced by agentk.
| Selector | Where |
|---|---|
[cmdk-root] |
Top-level element (Command / Command.Dialog) |
[cmdk-root][data-agentk-mode="<mode>"] |
Reflects the current state machine mode (browse, form, executing, result, planning, approval) |
[cmdk-root][data-agentk-hint] |
Present when AgentHint is showing |
[cmdk-overlay] |
Dialog backdrop |
[cmdk-dialog] |
Dialog surface |
[cmdk-input] |
Search input |
[cmdk-list] |
List wrapper (animatable via --cmdk-list-height) |
[cmdk-group], [cmdk-group-heading] |
Group + its heading |
[cmdk-empty] |
Empty state |
| Selector | Where |
|---|---|
[cmdk-item][data-agentk-tool="<name>"] |
The tool list item |
[data-agentk-tool-icon] |
Default icon span (only when tool.icon is set) |
[data-agentk-tool-name] |
Default label span |
[data-agentk-tool-description] |
Default description span |
[data-agentk-intent-trigger] |
A Command.IntentTrigger item |
| Selector | Where |
|---|---|
[data-agentk-form] |
Form container |
[data-agentk-form-invalid] |
Set on the form when validation has errors |
[data-agentk-form-heading] |
Header row (icon + title + description) |
[data-agentk-form-title] |
Form title |
[data-agentk-form-description] |
Form description |
[data-agentk-form-fields] |
Wrapper around the field list |
[data-agentk-form-field] |
Wrapper around a single field |
[data-agentk-form-field][data-agentk-field-error] |
Field whose value is invalid |
[data-agentk-form-label] |
Default field label |
[data-agentk-required] |
Required-field marker (default *) |
[data-agentk-form-hint] |
Field description / hint |
[data-agentk-field-error-message] |
Inline validation message |
[data-agentk-form-actions] |
Cancel + submit row |
[data-agentk-form-cancel] |
Cancel button |
[data-agentk-form-submit] |
Submit button |
| Selector | Where |
|---|---|
[data-agentk-result] |
Result container |
[data-agentk-result][data-agentk-executing] |
Set during the executing phase |
[data-agentk-result-loading] |
Spinner row during execution |
[data-agentk-progress] |
"Step N of M" indicator during a chained plan |
[data-agentk-result][data-agentk-success] |
Set when a successful result is shown |
[data-agentk-result][data-agentk-error] |
Set when an error is shown |
[data-agentk-result-heading] |
Result title row |
[data-agentk-result-body] |
Result content wrapper |
[data-agentk-result-data] |
The result value (string <span> or JSON <pre>) |
[data-agentk-result-error] |
The error message <pre> |
[data-agentk-result-meta] |
Meta row (e.g. duration) |
[data-agentk-result-dismiss] |
Default dismiss button |
| Selector | Where |
|---|---|
[data-agentk-agent-hint] |
Hint container (clickable) |
[data-agentk-agent-hint-icon] |
Default sparkle icon |
[data-agentk-agent-hint-content] |
Label + query wrapper |
[data-agentk-agent-hint-label] |
"Ask the agent" label |
[data-agentk-agent-hint-query] |
The current search text in quotes |
[data-agentk-agent-hint-kbd] |
Default ↵ kbd glyph |
| Selector | Where |
|---|---|
[data-agentk-approval] |
Approval container |
[data-agentk-approval-summary] |
Plan summary line |
[data-agentk-approval-calls] |
List of planned tool calls |
[data-agentk-approval-call] |
A single planned tool call |
[data-agentk-approval-call-icon] |
Tool icon (only when set) |
[data-agentk-approval-call-name] |
Tool name |
[data-agentk-approval-call-params] |
Parameter chips wrapper |
[data-agentk-approval-param] |
Single parameter chip |
[data-agentk-approval-param-value] |
Stringified parameter value |
[data-agentk-approval-actions] |
Reject + approve row |
[data-agentk-approval-reject] |
Reject button |
[data-agentk-approval-approve] |
Approve button |
| Selector | Where |
|---|---|
[data-agentk-planning] |
Planning indicator container |
[data-agentk-planning-text] |
"Thinking…" label |
[data-agentk-spinner] |
The animated spinner element (also reused inside [data-agentk-result-loading]) |
| Selector | Where |
|---|---|
[data-agentk-activity] |
Feed container |
[data-agentk-activity][data-agentk-activity-expanded] |
Set when the feed is expanded |
[data-agentk-activity-toggle] |
Expand/collapse button |
[data-agentk-activity-status] |
Latest status text |
[data-agentk-activity-chevron] |
Chevron icon (with data-expanded when open) |
[data-agentk-activity-entry] |
A feed entry |
[data-agentk-activity-entry][data-agentk-activity-type="<type>"] |
Entry type (tool_start, tool_complete, tool_error, etc.) |
[data-agentk-activity-icon] |
Per-entry icon |
[data-agentk-activity-message] |
Per-entry message |
cmdk compatible? Yes. agentk is a superset of cmdk. Existing cmdk code works unchanged — add tool props when you're ready.
Unstyled? Yes. All components expose data-attributes for styling. No CSS is bundled.
Which LLM providers? Anthropic, OpenAI, and Google (Gemini) are built in. Use provider: 'custom' with providerFn for anything else.
What is WebMCP? A browser API (document.modelContext, formerly navigator.modelContext) for registering tools that AI agents can discover. Chrome has it in origin trial. agentk makes your app WebMCP-ready.
Do I need WebMCP to use agentk? No. The command palette and tool execution work without it. WebMCP registration is opt-in.
Do I need an LLM to use agentk? No. Without the agent prop, agentk is a command palette with tool forms and execution — no AI required.
Auto-generated forms? Yes. inputSchema defines the form. enum → dropdown, number with min/max → slider, string → text input. Override with renderField for custom fields.
Human-in-the-loop? Set requireApproval: true and render <Command.Approval />. The agent's plan is shown to the user before any tools execute.
React 18+ only? Yes. Uses useId and useSyncExternalStore.
React server component? No, it's a client component.
Built on cmdk by Paco Coursey. Uses Radix UI primitives.