# 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](https://zidane.si/docs/extensions.md)).

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](#a-live-count-in-the-statusbar) | `ctx.storage.store`, `ctx.add(Slot)` | kitchen sink, `ui-items.ts` |
| [A progress line per session](#a-progress-line-per-session) | `Handle.update`, `timeoutMs` | `extensions/goal` |
| [A badge that lights up when a session waits](#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](#refuse-or-ask-for-risky-shell-commands) | `hooks.intercept('tool.call')` | kitchen sink, `hooks.ts` |
| [A prompt shortcut that never reaches the model](#a-prompt-shortcut-that-never-reaches-the-model) | `hooks.intercept('prompt')` | kitchen sink, `hooks.ts` |
| [Shrink large tool results](#shrink-large-tool-results) | `hooks.intercept('tool.result')`, `tools.metadata` | `extensions/headroom` |
| [Rules for one session](#rules-for-one-session) | `ctx.instructions.add` | kitchen sink, `sessions.ts` |
| [Ticket ids as chips, with a menu](#ticket-ids-as-chips-with-a-menu) | `Reference`, `BadgeAction` | kitchen sink, `extensions/linear` |
| [A project home block with a refresh](#a-project-home-block-with-a-refresh) | `Panel` with `surfaces: ['home']` | `extensions/git-flow` |
| [A settings button that writes other settings](#a-settings-button-that-writes-other-settings) | An `action` setting | kitchen sink, `extensions/headroom` |
| [A keymap mode](#a-keymap-mode) | `ctx.keymap.mode.push`, `mode` | kitchen sink, `commands.ts` |
| [Ask first, from any app](#ask-first-from-any-app) | `ctx.ui.confirm`, `NoUiError` | kitchen sink, `commands.ts` |
| [A note with a custom block](#a-note-with-a-custom-block) | `ctx.sessions.note`, `Renderer` | kitchen sink, `sessions.ts` |
| [A component that calls the extension](#a-component-that-calls-the-extension) | `useRpc` | kitchen sink, `ui.tsx` |
| [A provider that signs in through an integration](#a-provider-that-signs-in-through-an-integration) | `ctx.integrations` | `extensions/bolt` |
| [Test a command behind a dialog](#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.

```ts
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](https://zidane.si/docs/extensions-ui.md#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.

```ts
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.

```ts
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](https://zidane.si/docs/extensions-placements.md#composerbadges)).

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

```ts
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](https://zidane.si/docs/extensions-hooks.md)).

## A prompt shortcut that never reaches the model

A `prompt` interceptor sees what the user sends. `{ handled: true }` stops it there.

```ts
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.

```ts
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.

```ts
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.

```ts
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](https://zidane.si/docs/extensions-ui.md#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.

```ts
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.

```ts
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](https://zidane.si/docs/extensions-settings.md)).

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

```ts
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](https://zidane.si/docs/extensions-commands.md)).

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

```ts
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](https://zidane.si/docs/extensions-placements.md#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.

```ts
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](https://zidane.si/docs/extensions-components.md)).

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

```tsx title="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](https://zidane.si/docs/extensions-rpc.md)).

## A provider that signs in through an integration

The integration owns the sign-in. The provider asks for a fresh credential before each call.

```ts
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](https://zidane.si/docs/extensions-integrations.md)).

## Test a command behind a dialog

Queue the answer, run the command, check what happened.

```ts
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](https://zidane.si/docs/extensions-testing.md)).

## 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](https://zidane.si/docs/extensions-placements.md) 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.
