Extension cookbook
Short recipes for common extension tasks, taken from the kitchen-sink example and the extensions in the zidane repository.
Each recipe solves one task in a few lines, and names the file it comes from. The kitchen-sink example (examples/extension-kitchen-sink/) uses every part of the API. The extensions in the repository's extensions/ folder are real ones, installed by the setup wizard. Snippets use ctx, the context setup receives (overview).
Use this page to find a starting point. Each recipe links to the page with the full API.
| Recipe | Uses | Source |
|---|---|---|
| A live count in the statusbar | ctx.storage.store, ctx.add(Slot) | kitchen sink, ui-items.ts |
| A progress line per session | Handle.update, timeoutMs | extensions/goal |
| A badge that lights up when a session waits | composer.badges, hooks.on | kitchen sink, ui-items.ts |
| Refuse or ask for risky shell commands | hooks.intercept('tool.call') | kitchen sink, hooks.ts |
| A prompt shortcut that never reaches the model | hooks.intercept('prompt') | kitchen sink, hooks.ts |
| Shrink large tool results | hooks.intercept('tool.result'), tools.metadata | extensions/headroom |
| Rules for one session | ctx.instructions.add | kitchen sink, sessions.ts |
| Ticket ids as chips, with a menu | Reference, BadgeAction | kitchen sink, extensions/linear |
| A project home block with a refresh | Panel with surfaces: ['home'] | extensions/git-flow |
| A settings button that writes other settings | An action setting | kitchen sink, extensions/headroom |
| A keymap mode | ctx.keymap.mode.push, mode | kitchen sink, commands.ts |
| Ask first, from any app | ctx.ui.confirm, NoUiError | kitchen sink, commands.ts |
| A note with a custom block | ctx.sessions.note, Renderer | kitchen sink, sessions.ts |
| A component that calls the extension | useRpc | kitchen sink, ui.tsx |
| A provider that signs in through an integration | ctx.integrations | extensions/bolt |
| Test a command behind a dialog | createTestHost | kitchen sink, index.test.ts |
A live count in the statusbar#
Bind the item's text to a store. The item follows the store in every window, with no update call.
import { Slot } from 'zidane'
import { z } from 'zod'
const counter = ctx.storage.store('counter', z.object({ count: z.number() }), { count: 0 }, { scope: 'project' })
ctx.commands.register({ id: 'bump', title: 'Bump', run: () => counter.set(c => ({ count: c.count + 1 })) })
ctx.add(Slot, { at: 'app.statusbar', text: counter.select(c => `count ${c.count}`), command: 'bump' })From ui-items.ts. More on bound fields.
A progress line per session#
Keep one handle per session. Update it in place while the work runs, then replace it with a final line that goes away on its own.
import type { Handle, SlotItem } from 'zidane'
import { Slot } from 'zidane'
const lines = new Map<string, { handle: Handle<SlotItem>, final: boolean }>()
function status(sessionId: string, text: string | null, final = false): void {
const line = lines.get(sessionId)
if (text === null) {
line?.handle.dispose()
lines.delete(sessionId)
return
}
if (line && !line.final && !final) {
line.handle.update({ text })
return
}
line?.handle.dispose()
const handle = ctx.add(Slot, { at: 'app.statusbar', sessionId, text, loader: !final, ...(final ? { timeoutMs: 5_000 } : {}) })
lines.set(sessionId, { handle, final })
}From extensions/goal/index.ts. A session's items go when the session is deleted, so the map only needs null for work that ends.
A badge that lights up when a session waits#
Add the badge the first time a session runs, and raise it while the session waits for an answer.
import type { Handle, SlotItem } from 'zidane'
import { Slot } from 'zidane'
const badges = new Map<string, Handle<SlotItem>>()
const badge = (sessionId: string): Handle<SlotItem> => {
let handle = badges.get(sessionId)
if (!handle) {
handle = ctx.add(Slot, { at: 'composer.badges', sessionId, text: 'kitchen', panel: 'board' })
badges.set(sessionId, handle)
}
return handle
}
ctx.hooks.on('run:settled', event => badge(event.sessionId).update({ highlight: false }))
ctx.hooks.on('session:event', event => badge(event.sessionId).update({ highlight: event.kind === 'needs-attention' }))From ui-items.ts. A click opens the board panel (composer badges).
Refuse or ask for risky shell commands#
An interceptor on tool.call blocks a call, sends it through approval, or lets it pass. match limits it to the shell tool.
ctx.hooks.intercept('tool.call', (call) => {
const command = typeof call.input.command === 'string' ? call.input.command : ''
if (/\brm\s+-rf\s+\/(?:\s|$)/.test(command))
return { block: 'Refused: rm -rf /.' }
if (/\bgit\s+push\b.*--force/.test(command))
return 'ask'
return undefined
}, { match: ['shell'] })From hooks.ts. undefined keeps what earlier interceptors decided (hooks).
A prompt shortcut that never reaches the model#
A prompt interceptor sees what the user sends. { handled: true } stops it there.
ctx.hooks.intercept('prompt', async (input) => {
if (input.text.trim() !== '!bump')
return undefined
await ctx.commands.run('kitchen-sink.bump')
return { handled: true }
})From hooks.ts. Prompt interceptors run in the terminal, desktop and web apps, and for ctx.sessions.submit.
Shrink large tool results#
A tool.result interceptor returns the result the model sees and the session stores. Put what you want to keep in metadata, under your own namespace.
ctx.tools.metadata('shrink', {
contextRows: ({ entries }) => [{ label: 'Shrunk results', value: String(entries.length) }],
})
ctx.hooks.intercept('tool.result', (call, current) => {
if (typeof current.result !== 'string' || current.result.length < 50_000 || call.tool === 'read_file')
return undefined
const kept = current.result.slice(0, 20_000)
return { ...current, result: `${kept}\n[cut ${current.result.length - kept.length} characters]`, metadata: { shrink: { from: current.result.length } } }
})Adapted from extensions/headroom/setup.ts, which compresses through a proxy instead. The same interceptor covers sub-agent calls.
Rules for one session#
ctx.instructions.add adds text to the system prompt. With sessionId and key, a second call with the same key replaces the first.
ctx.commands.register({
id: 'strict',
title: 'Strict mode for this session',
when: 'session',
run: ({ sessionId }) => {
if (sessionId)
ctx.instructions.add('Ask before you edit any file.', { sessionId, key: 'strict' })
},
})From sessions.ts. Without options, the text applies to every session until the extension unloads.
Ticket ids as chips, with a menu#
A reference turns KS-12 in text into a chip. A badge action adds an entry to the chip's right-click menu.
import { BadgeAction, Reference } from 'zidane'
ctx.add(Reference, {
id: 'kitchen-sink.ticket',
patterns: [{ regex: '\\bKS-(\\d+)\\b', key: '$1' }],
resolve: async key => ({ label: `KS-${key}`, status: 'queued', tone: 'accent', href: `https://tickets.example.com/KS-${key}` }),
})
ctx.add(BadgeAction, {
id: 'kitchen-sink.ticket-actions',
match: { references: ['kitchen-sink.ticket'] },
actions: () => [{ id: 'serve', label: 'Serve this ticket', group: 'more' }],
run: async (_actionId, target) => ({ notice: `Served ${target.text}`, insert: `Serve ${target.text}` }),
})From ui-items.ts. extensions/linear does the same for Linear tickets, with real lookups. The desktop and web apps draw chips (references).
A project home block with a refresh#
A panel with surfaces: ['home'] is a block on the desktop app's project home. Its subscribe hands you an emit to call when the content changes.
import { Panel } from 'zidane'
const emits = new Set<() => void>()
let cached: { at: number, count: number } | undefined
ctx.add(Panel, {
id: 'prs',
label: 'Pull requests',
icon: { gui: 'i-ph-git-pull-request', tui: '⇄' },
surfaces: ['home'],
home: { order: 20, width: 'half' },
load: async () => {
if (!cached || Date.now() - cached.at > 60_000)
cached = { at: Date.now(), count: 3 }
return { sections: [{ id: 'open', title: 'Open', items: [{ kind: 'markdown', text: `${cached.count} open` }] }], actions: [{ id: 'refresh', label: 'Refresh' }] }
},
onAction: (actionId) => {
if (actionId !== 'refresh')
return
cached = undefined
for (const emit of emits) emit()
},
subscribe: (emit) => {
emits.add(emit)
return () => emits.delete(emit)
},
})From extensions/git-flow/prs-block.ts. The terminal and web apps have no project home: add 'side' to surfaces to show it there as a tab.
A settings button that writes other settings#
An action setting has no ctx. Its run gets the extension's own settings, and may write them.
import type { SettingsSchema } from 'zidane'
export const settings = {
items: {
greeting: { type: 'string', label: 'Greeting', default: 'Hello' },
resetGreeting: {
type: 'action',
label: 'Reset the greeting',
button: 'Reset',
run: async ({ get, set }) => {
const was = get('greeting')
await set('greeting', 'Hello')
return { message: `Greeting reset (was "${String(was)}").`, tone: 'success' }
},
status: ({ get }) => ({ text: `now "${String(get('greeting'))}"`, tone: 'muted' }),
},
},
} as const satisfies SettingsSchemaFrom settings.ts. extensions/headroom uses the same shape to start a Docker container and point baseUrl at it (settings).
A keymap mode#
A mode is a layer of keys that fire only while it is on. Here J bumps and Q leaves the mode.
import type { Disposable } from 'zidane'
const MODE = 'kitchen-sink.cooking'
let cooking: Disposable | undefined
const leave = (): void => {
cooking?.dispose()
cooking = undefined
}
ctx.commands.register({ id: 'cooking-mode', title: 'Toggle cooking mode', bind: 'mod+shift+u', run: () => {
if (cooking)
leave()
else
cooking = ctx.keymap.mode.push(MODE)
} })
ctx.commands.register({ id: 'cooking-bump', title: 'Bump (cooking mode)', palette: false, bind: 'j', mode: MODE, hint: true, run: () => void ctx.commands.run('kitchen-sink.bump') })
ctx.commands.register({ id: 'cooking-exit', title: 'Leave cooking mode', palette: false, bind: 'q', mode: MODE, run: leave })From commands.ts. hint: true shows J in the terminal app's footer while the mode is on (commands).
Ask first, from any app#
A dialog goes to the window that views the session. With no window, it rejects with NoUiError: decide what the command does then.
import { NoUiError } from 'zidane'
ctx.commands.register({
id: 'reset',
title: 'Reset the counter',
when: 'idle',
run: async ({ sessionId }) => {
try {
if (!await ctx.ui.confirm({ title: 'Reset the counter?', confirmLabel: 'Reset', danger: true, ...(sessionId ? { sessionId } : {}) }))
return
}
catch (error) {
if (error instanceof NoUiError)
return
throw error
}
ctx.logger.info('reset')
},
})From commands.ts (dialogs).
A note with a custom block#
ctx.sessions.note adds blocks to the transcript that the model never sees. A custom block draws through a renderer, and its text is the fallback.
import { Renderer } from 'zidane'
ctx.add(Renderer, { for: { block: 'kitchen-card' }, component: { component: 'KitchenCard' } })
ctx.commands.register({
id: 'card',
title: 'Show the kitchen card',
when: 'session',
run: async ({ sessionId }) => {
if (sessionId)
await ctx.sessions.note(sessionId, [{ text: 'Counter: 3', custom: { renderer: 'kitchen-card', data: { count: 3 } } }])
},
})From sessions.ts. KitchenCard is exported by ui.tsx and tui.tsx (components).
A component that calls the extension#
The component reaches the extension through RPC. tui.tsx takes the contract itself. ui.tsx cannot import values from zidane, so it names the contract by id and types the client by hand.
import { buttonClass, useRpc } from 'zidane-ui/extensions/kit'
interface Client { bump: (input: { by: number }) => Promise<{ count: number }> }
export function BumpButton() {
const rpc = useRpc<Client>('kitchen-sink')
return <button type="button" className={buttonClass()} onClick={() => void rpc.bump({ by: 1 })}>Bump</button>
}From ui.tsx and tui.tsx (RPC).
A provider that signs in through an integration#
The integration owns the sign-in. The provider asks for a fresh credential before each call.
async function token(): Promise<string> {
const connection = ctx.integrations.connection('bolt')
if (!connection)
throw new Error('Sign in to Bolt in Settings first.')
const credential = await ctx.integrations.resolve(connection)
if (credential.type !== 'oauth')
throw new Error('Bolt needs an OAuth sign-in.')
return credential.accessToken
}From extensions/bolt/index.ts. resolve refreshes an expired token (integrations).
Test a command behind a dialog#
Queue the answer, run the command, check what happened.
import { expect, it } from 'bun:test'
import { defineExtension } from 'zidane'
import { createTestHost } from 'zidane/extensions/testing'
const ext = defineExtension({
apiVersion: 1,
id: 'resetter',
setup(ctx) {
ctx.commands.register({ id: 'reset', title: 'Reset', run: async () => {
if (await ctx.ui.confirm({ title: 'Reset?' }))
await ctx.storage.kv('user').set('count', 0)
} })
},
})
it('resets only after a yes', async () => {
const host = await createTestHost({ extensions: [ext] })
await host.ctx('resetter').storage.kv('user').set('count', 3)
host.dialogs.answer('confirm', false)
await host.commands.run('resetter.reset')
expect(host.ctx('resetter').storage.kv('user').get('count')).toBe(3)
host.dialogs.answer('confirm', true)
await host.commands.run('resetter.reset')
expect(host.ctx('resetter').storage.kv('user').get('count')).toBe(0)
await host.dispose()
})From index.test.ts (testing).
In each app#
The recipes run in every app. What the user sees differs:
| Recipe | Terminal app | Desktop app | Web app |
|---|---|---|---|
| Statusbar count, progress line | A line above the prompt | The strip at the bottom of the sidebar | The strip at the bottom of the sidebar |
| Session badge | A plain label above the prompt | A badge that opens the panel | A badge that opens the panel |
| Ticket chips | Not drawn. Badge actions show in the artifacts menu | Chips with a right-click menu | Chips with a right-click menu |
| Project home block | Not drawn | A block on the project home | Not drawn |
| Confirm dialog | A box over the app | A dialog | A dialog |
| Component | tui.tsx | ui.tsx | ui.tsx |
Placements has the details.
Pitfalls#
- Recipes that keep handles in a
Maphold them until the extension unloads. Dispose what you no longer show, or add the items throughctx.scope(). - Interceptors run on every call of their kind. Return
undefinedearly when the call is not yours, before any slow work. - A recipe that needs a window (a dialog, a panel open) fails or answers
falseon a headless server. Keep a path that works without one. ctx.commands.runtakes the published id,<extension>.<id>, even for your own commands. A slot's or a badge'scommandalso takes a bare id for your own.