2. The three plugin shapes, with minimal complete examples

August 14, 2026 · View on GitHub

2.1 Tool plugin — defineTool()

A tool is the plugin type an agent calls. Declare the parameter schema (auto-validated, types args), the canonical JSON output, and the execute body:

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'spike-tool-time'

// The plugin only activates once the host's `tools` registry is ready.
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'spike_env_time',
    description: 'Return the current time and process environment info.',
    parameters: {
      tz: {
        type: 'string',
        description: "IANA timezone name, e.g. 'Asia/Shanghai'. Defaults to the system local timezone.",
      },
    },
    output: {
      schema: {
        type: 'object',
        properties: {
          iso: { type: 'string', description: 'ISO-8601 timestamp (UTC).' },
          unixMs: { type: 'integer', description: 'Unix epoch milliseconds.' },
          tz: { type: 'string', description: 'Timezone actually used.' },
          nodeVersion: { type: 'string', description: 'process.version' },
          platform: { type: 'string', description: 'process.platform' },
        },
        additionalProperties: false,
      },
      render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
    },
    async execute(args) {
      const tz = args.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
      const now = new Date()
      return {
        iso: now.toISOString(),
        unixMs: now.getTime(),
        tz,
        nodeVersion: process.version,
        platform: process.platform,
      }
    },
  }))

  // Self-check: prove the tool actually landed in the registry.
  console.log(
    `[spike-tool-time] registered "spike_env_time" — listed=${ctx.tools.get('spike_env_time') !== undefined}`,
  )
}

Screenshot: 02-plugin-code (this file rendered).

What matters here:

  • Structured return, not prose. output.schema declares a canonical JSON value; render() projects it into model-facing content blocks. In Code Mode (PTC) the schema becomes await tools.spike_env_time(...) automatically.
  • Validation is free. parameters is validated before execute runs; args is typed from it.
  • Reversible. ctx.tools.register() returns a disposer and auto-attaches it to this plugin's fiber — unloading the plugin unregisters the tool.
  • Object schemas must declare additionalProperties. Mark each returned field required: true so value stays non-optional in render()/presentationMeta().

2.2 Event / lifecycle plugin — ctx.on + ctx.effect

This plugin has zero runtime dependencies: it only uses the ctx the host hands it. Every import type is erased at compile time.

import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

export const name = 'spike-lifecycle-logger'

export function apply(ctx: Context) {
  let sessionEvents = 0
  let toolChanges = 0
  let toolPreExecutes = 0

  // ① Durable session firehose (emit): fires whenever a session's log grows.
  ctx.on('session/event', (session: Session, event: SessionEvent) => {
    sessionEvents += 1
    if (sessionEvents <= 5 || sessionEvents % 25 === 0) {
      console.log(`[spike-lifecycle] session/event #${sessionEvents} type=${event.type} session=${String(session.id)}`)
    }
  })

  // ② Live registry change (emit): fires when any tool is registered or unregistered.
  ctx.on('tools/change', () => {
    toolChanges += 1
    console.log(`[spike-lifecycle] tools/change #${toolChanges}`)
  })

  // ③ Tool execution pipeline (waterfall): log, then delegate with next().
  //    NOT calling next() would short-circuit and block the tool call.
  ctx.on('tools/pre-execute', (exec: ToolExecution, next: () => Promise<PreToolDecision>) => {
    toolPreExecutes += 1
    console.log(`[spike-lifecycle] tools/pre-execute #${toolPreExecutes} tool=${exec.name}`)
    return next()
  })

  // ④ A non-Cordis resource (a timer) wrapped in ctx.effect().
  //    The returned disposer runs on unload — the reversible-cleanup proof.
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log(`[spike-lifecycle] heartbeat sessionEvents=${sessionEvents} toolPreExecutes=${toolPreExecutes} toolChanges=${toolChanges}`)
    }, 30_000)
    return () => {
      clearInterval(timer)
      console.log('[spike-lifecycle] DISPOSED — listeners removed, timer cleared')
    }
  })

  console.log('[spike-lifecycle] listeners registered: session/event + tools/change + tools/pre-execute')
}

The event seam (docs/event-producer-consumer.md is the full matrix):

Dispatch modeAwaited?OrderReturn value?
emitnoregistration orderno
waterfallnoregistration order (around-middleware)yes
parallelyesparallelno
serialyesregistration orderyes

The tool execution pipeline is where you intercept tool calls:

declare module '@deepseek-ai/cordis' {
  interface Events {
    'tools/pre-execute'(this, exec, next): Promise<PreToolDecision>    // waterfall: allow/deny/ask
    'tools/execute'(this, exec, next): Promise<ToolExecutionResult>    // waterfall: timeout/retry/metrics
    'tools/post-execute'(this, exec, result, next): Promise<PostToolDecision> // waterfall: replace/intercept
    'tools/result'(this, exec, result): undefined                      // emit: observe the frozen result
    'tools/change'(): void                                             // emit: tool set changed
  }
}

2.3 Web UI extension — tool cards and panels

There are two Web UI extension points: the tool card (per-tool render intent) and the panel (a whole new piece of browser UI via a dual-half plugin).

2.3.1 Tool card — presentCall / presentResult

A tool can render its call/result as a card instead of plain text. presentCall shows a pending card when the model calls the tool; presentResult rebuilds the completed card from the persisted meta. Both must be pure (they run on live streaming and on session-log replay).

import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'my-webui'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'my_note',
    description: 'Write a short note to a file and show an inline diff card (Web UI extension demo).',

    parameters: {
      path: { type: 'string', required: true, description: 'Absolute path to write.' },
      content: { type: 'string', required: true, description: 'Note content.' },
    },

    output: {
      schema: {
        type: 'object',
        properties: {
          path: { type: 'string', required: true, description: 'Absolute path written.' },
          bytes: { type: 'integer', required: true, description: 'Bytes written.' },
        },
        additionalProperties: false,
      },
      render: (_args, value) => [{ type: 'text', text: `Wrote ${value.bytes} bytes to ${value.path}` }],
      // Replayable card data: combine args + canonical value so the card can be
      // rebuilt from the persisted tool/result event on replay.
      presentationMeta: (args, value) => ({ path: value.path, content: args.content }),
    },

    // Pending card (a diff card — this call creates a file, so oldText is null).
    presentCall: (args) => ({
      card: 'diff',
      title: `Write ${args.path}`,
      diffs: [{ path: args.path, oldText: null, newText: args.content }],
      locations: [{ path: args.path }],
    }),

    // Completed card: rebuild the applied hunk from the persisted meta.
    presentResult: (_args, result) => {
      const meta = result.meta as { path?: string; content?: string } | undefined
      const path = meta?.path ?? ''
      return {
        card: 'diff',
        title: `Wrote ${path}`,
        diffs: [{ path, oldText: null, newText: meta?.content ?? '' }],
      }
    },

    async execute(args) {
      const abs = resolve(args.path)
      await writeFile(abs, args.content, 'utf8')
      return { path: abs, bytes: Buffer.byteLength(args.content, 'utf8') }
    },
  }))
}

2.3.2 Panel — a dual-half plugin with slot registration

A real browser panel is a dual-half plugin: one npm package with a host half (Node process, exports["."]) and a browser half (exports["./client"]). The browser half is a Cordis plugin that registers a React component into a UI slot with ctx.slots.register(...). There is no separate "panel API" — a panel is just a slot registration.

package.json declares the browser half:

{
  "name": "panel-spike",
  "version": "0.1.0",
  "type": "module",
  "main": "lib/index.js",                 // host half
  "exports": {
    ".": "./lib/index.js",                // host half (Node process)
    "./client": "./lib/client.js",        // browser half (Web UI process)
    "./package.json": "./package.json"
  },
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": {
      "inject": [
        "@deepseek-ai/dsh-client-runtime",
        "@deepseek-ai/dsh-client-ui-slots"
      ],
      "platform": "web"
    }
  }
}

The browser half (lib/client.js) — a self-registering closure factory, no build step needed:

window.__ModuleLoader__.load({
  id: 'panel-spike',                       // must equal package.json's `name`
  factory: (require) => {
    const React = require('react')         // `react` is a shell-provided module entry
    return {
      inject: ['slots'],                   // inject the runtime's slots service
      apply(ctx) {
        ctx.slots.register(
          { name: 'shell.overlay', id: 'panel-spike', order: 0 },
          () => React.createElement('div', {
            style: {
              position: 'fixed', top: '16px', right: '16px', zIndex: 9999,
              background: '#0b1220', color: '#7ee787', border: '1px solid #30363d',
              borderRadius: '8px', padding: '12px 16px', fontFamily: 'monospace',
              fontSize: '14px', pointerEvents: 'auto',
            },
          }, 'panel-spike: DSH Web UI panel API OK ✓'),
        )
      },
    }
  },
})

The host half (lib/index.js) — usually left empty for a minimal panel:

export const name = 'panel-spike'
export function apply() {}

Key facts about panels (from a verified spike against npx @deepseek-ai/dsh web, port 3080):

  • Install into the built-in web profile, not a new one: dsh plugin --profile web add ./panel-spike. A new profile defaults to an agent profile — no Web UI (@deepseek-ai/dsh-web* is web search, not the UI).
  • Use a list slot for "add a panel": shell.overlay (floating layer) or sidebar.footer.action (sidebar action). single slots (root / sidebar / conversation / details) are replace-the-whole-thing and throw on double registration.
  • list slots require an id; the client bundle's id must equal the package name.
  • The browser half only registers a factoryapply runs when the factory materializes; don't do DOM work at module top level.
  • The host half owns the data (fs / git / HTTP routes / SSE); the browser half reaches it via /xxx/* routes.
  • An apply that throws fails the whole web-shell boot — wrap DOM wiring in try/catch or an error boundary.

Screenshots: 04-web-ui-home (Web UI home) and 05-panel-spike (the shell.overlay panel, top-right). Full panel reference: research/webui-panel-api.md.


Prev: The plugin model · Contents · Next: The 15 design principles