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:
(this file rendered).
What matters here:
- Structured return, not prose.
output.schemadeclares a canonical JSON value;render()projects it into model-facing content blocks. In Code Mode (PTC) the schema becomesawait tools.spike_env_time(...)automatically. - Validation is free.
parametersis validated beforeexecuteruns;argsis 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 fieldrequired: truesovaluestays non-optional inrender()/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 mode | Awaited? | Order | Return value? |
|---|---|---|---|
emit | no | registration order | no |
waterfall | no | registration order (around-middleware) | yes |
parallel | yes | parallel | no |
serial | yes | registration order | yes |
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
webprofile, 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
listslot for "add a panel":shell.overlay(floating layer) orsidebar.footer.action(sidebar action).singleslots (root/sidebar/conversation/details) are replace-the-whole-thing and throw on double registration. listslots require anid; the client bundle'sidmust equal the packagename.- The browser half only registers a factory —
applyruns 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
applythat throws fails the whole web-shell boot — wrap DOM wiring in try/catch or an error boundary.
Screenshots:
(Web UI home) and
(the
shell.overlaypanel, top-right). Full panel reference:research/webui-panel-api.md.
← Prev: The plugin model · Contents · Next: The 15 design principles →
(this file rendered).
(Web UI home) and
(the