Skip to content

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.

RecipeUsesSource
A live count in the statusbarctx.storage.store, ctx.add(Slot)kitchen sink, ui-items.ts
A progress line per sessionHandle.update, timeoutMsextensions/goal
A badge that lights up when a session waitscomposer.badges, hooks.onkitchen sink, ui-items.ts
Refuse or ask for risky shell commandshooks.intercept('tool.call')kitchen sink, hooks.ts
A prompt shortcut that never reaches the modelhooks.intercept('prompt')kitchen sink, hooks.ts
Shrink large tool resultshooks.intercept('tool.result'), tools.metadataextensions/headroom
Rules for one sessionctx.instructions.addkitchen sink, sessions.ts
Ticket ids as chips, with a menuReference, BadgeActionkitchen sink, extensions/linear
A project home block with a refreshPanel with surfaces: ['home']extensions/git-flow
A settings button that writes other settingsAn action settingkitchen sink, extensions/headroom
A keymap modectx.keymap.mode.push, modekitchen sink, commands.ts
Ask first, from any appctx.ui.confirm, NoUiErrorkitchen sink, commands.ts
A note with a custom blockctx.sessions.note, Rendererkitchen sink, sessions.ts
A component that calls the extensionuseRpckitchen sink, ui.tsx
A provider that signs in through an integrationctx.integrationsextensions/bolt
Test a command behind a dialogcreateTestHostkitchen 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

TypeScript
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 SettingsSchema

From 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.

TypeScript
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.

TypeScript
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.

TypeScript
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.

ui.tsx
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.

TypeScript
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.

TypeScript
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:

RecipeTerminal appDesktop appWeb app
Statusbar count, progress lineA line above the promptThe strip at the bottom of the sidebarThe strip at the bottom of the sidebar
Session badgeA plain label above the promptA badge that opens the panelA badge that opens the panel
Ticket chipsNot drawn. Badge actions show in the artifacts menuChips with a right-click menuChips with a right-click menu
Project home blockNot drawnA block on the project homeNot drawn
Confirm dialogA box over the appA dialogA dialog
Componenttui.tsxui.tsxui.tsx

Placements has the details.

Pitfalls#

  • Recipes that keep handles in a Map hold them until the extension unloads. Dispose what you no longer show, or add the items through ctx.scope().
  • Interceptors run on every call of their kind. Return undefined early when the call is not yours, before any slow work.
  • A recipe that needs a window (a dialog, a panel open) fails or answers false on a headless server. Keep a path that works without one.
  • ctx.commands.run takes the published id, <extension>.<id>, even for your own commands. A slot's or a badge's command also takes a bare id for your own.