Dev.to AI 🤖 Ai 👁 0 📖 11 min read

DeepSeek Harness Series (10): Building a Complete dsh Plugin — From Requirements to Production

From 'Knowing' to 'Doing' The previous nine articles covered a lot of ground: the Cordis plugin system, tool registration, the Agent loop, Session memory, System Prompt assembly, capability seams, multi-Agent collabora

From 'Knowing' to 'Doing'

The previous nine articles covered a lot of ground: the Cordis plugin system, tool registration, the Agent loop, Session memory, System Prompt assembly, capability seams, multi-Agent collaboration, observability...

Knowing what each piece does, and actually assembling them into something that runs, are two different things.

This article does the second one.

We'll build a complete plugin from scratch — from the requirements description to code you can drop into a Bundle. The knowledge from the previous nine articles won't be recited in a list — it will show up naturally in the code. Where something needs explanation, there's a note with a chapter reference.

What We're Building

Plugin name: workspace-context

Feature requirements:

  1. Inject the current working directory into the system prompt (so the model 'knows where it is')
  2. Provide a get_workspace_info tool (so the model can actively query workspace files)
  3. Pause before dangerous tool calls (names containing delete or rm) and wait for user confirmation
  4. Measure execution time for every tool call (observability)
  5. When the plugin is unloaded, all registrations clean up automatically

These five features map directly to the five main concerns of plugin development: prompt + tools + permissions + observability + lifecycle.

The Plugin Skeleton

A dsh plugin is a TypeScript module that follows the Cordis plugin protocol:

// packages/workspace-context/src/index.ts

// Plugin name: unique identifier in the Cordis dependency tree
export const name = 'workspace-context'

// Declare service dependencies.
// Cordis ensures these services are ready before calling apply.
// If a service is unavailable, the plugin suspends (no crash — just waits).
export const inject = ['tools', 'systemPrompt']

// Plugin entry point.
// ctx is the private Cordis context for this plugin.
// Everything registered through ctx (tools, prompt sections, event listeners)
// is bound to this context's lifecycle — automatically disposed when the plugin unloads.
export function apply(ctx: Context): void {
  // All registration goes here
}

These three declarations (name, inject, apply) are the minimum structure for a dsh plugin.

inject isn't just an import list — it's a contract. It tells the Cordis runtime: 'I depend on these services. If they're not ready yet, don't call me.' This is the foundation for hot-swapping (covered in article 02 on the Cordis plugin system).

Step 1: Inject a System Prompt Section

The cleanest way to make the model aware of the current working directory is through a System Prompt Section — not by repeating it in every user message (article 06: System Prompt Assembly).

// Inject workspace information into the system prompt
ctx.systemPrompt.section({
  name: 'workspace-context:cwd',
  // Place this section after Agent instructions, before user-facing prompts
  order: ctx.systemPrompt.getSectionOrder('context:workspace'),
  // Dynamic text: re-evaluated every time the system prompt is assembled.
  // If the working directory changes mid-session, the next request gets the fresh value.
  text: (context) => {
    const cwd = context.agent?.session?.header?.cwd
    if (!cwd) return ''  // No cwd available — inject nothing
    return [
      '## Workspace',
      `Current working directory: ${cwd}`,
      'All relative file paths are resolved from this directory.',
    ].join('\n')
  },
})

Notice that text is a function, not a string. dsh calls it each time it assembles the system prompt — so the injected information is always current, never a stale snapshot.

One more detail: we don't need to save the return value of section() (which is a disposer). Because we used ctx.systemPrompt.section(), Cordis tracks that this Section belongs to the current plugin's context and will clean it up automatically when the plugin unloads.

Step 2: Register the get_workspace_info Tool

A tool lets the model actively 'ask' rather than just 'be told' (article 03: Tool System).

import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'

ctx.tools.register(defineTool({
  name: 'get_workspace_info',
  description: 'Get information about the current workspace: directory listing and file sizes.',

  parameters: {
    path: {
      type: 'string',
      description: 'Relative path within the workspace. Defaults to workspace root.',
    },
  },

  output: {
    schema: {
      // Strict mode: additionalProperties: false prevents unexpected fields from the model
      type: 'object',
      additionalProperties: false,
      properties: {
        path: { type: 'string' },
        entries: {
          type: 'array',
          items: {
            type: 'object',
            additionalProperties: false,
            properties: {
              name: { type: 'string' },
              type: { type: 'string', enum: ['file', 'directory'] },
              size: { type: 'number' },
            },
          },
        },
      },
    },
    // render converts the structured return value into model-readable text.
    // Separating 'structured data' from 'what the model sees' is a core dsh design principle.
    render: (_args, value) => {
      const v = value as {
        path: string
        entries: Array<{ name: string; type: string; size: number }>
      }
      const lines = [`Directory: ${v.path}`, '']
      for (const entry of v.entries) {
        const sizeStr = entry.type === 'file' ? ` (${entry.size} bytes)` : '/'
        lines.push(`  ${entry.name}${sizeStr}`)
      }
      return [{ type: 'text', text: lines.join('\n') }]
    },
  },

  // Read-only operation. Doesn't modify any state.
  // Safe to run in parallel with other tools.
  isConcurrencySafe: () => true,

  async execute(args, exec) {
    // Pull cwd from the session header; fall back to process.cwd() if absent
    const cwd = exec.agent?.session?.header?.cwd ?? process.cwd()
    const targetPath = args.path ? join(cwd, args.path) : cwd

    const entries = await readdir(targetPath, { withFileTypes: true })
    const result = await Promise.all(
      entries.map(async (entry) => {
        const fullPath = join(targetPath, entry.name)
        // Only call stat on files (to get size) — directories don't need it
        const stats = entry.isFile() ? await stat(fullPath) : null
        return {
          name: entry.name,
          type: entry.isDirectory() ? 'directory' : 'file',
          size: stats?.size ?? 0,
        }
      })
    )

    return {
      path: targetPath,
      // Directories first, then files; alphabetical within each group
      entries: result.sort((a, b) => {
        if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
        return a.name.localeCompare(b.name)
      }),
    }
  },
}))

isConcurrencySafe: () => true is a performance signal. When dsh sees multiple tool call requests in the same Agent loop turn, it can run concurrency-safe tools in parallel instead of serializing them. For read-only tools, this should almost always be true.

Step 3: Permission Interception

Dangerous operations — deletions, overwrites — should give a human a chance to intervene before they run. dsh handles this via the tools/pre-execute event (article 03):

// Intercept tool calls whose names contain 'delete' or 'rm'.
// Returning { kind: 'ask' } pauses execution and waits for user confirmation.
// Returning { kind: 'deny' } rejects the call immediately — the tool never runs.
ctx.on('tools/pre-execute', async (exec, next) => {
  const isDangerous = exec.name.includes('delete') || exec.name.includes('rm')

  // Not dangerous: let it through, continue the execution chain
  if (!isDangerous) return next()

  // Dangerous: require confirmation.
  // If no approval service is configured in the Bundle,
  // 'ask' automatically degrades to 'deny'.
  return {
    kind: 'ask',
    reason: `About to run: ${exec.name}(${exec.arguments})`,
  }
})

This is the middleware pattern: next() means 'continue the chain'. Returning something else without calling next() short-circuits the chain — the tool is never executed.

There's also a design intent here: instead of a whitelist of safe tool names, the check is based on naming conventions. Real projects might need more precise rules, but the shape is the same.

Step 4: Execution Time Metering

In production, you need to know which tools are slowest and which calls take suspiciously long (article 09: Observability):

// Wrap every tool execution to measure elapsed time.
// tools/execute is a middleware hook — every tool call passes through it.
ctx.on('tools/execute', async (exec, next) => {
  const start = performance.now()

  // Call next() to run the actual tool implementation
  const result = await next()

  const elapsed = Math.round(performance.now() - start)

  // result.isError is the standardized error flag.
  // Here we log to console; in a real project, route to ctx.sessionTelemetry.
  console.log(`[${exec.name}] ${result.isError ? 'ERROR' : 'OK'} ${elapsed}ms`)

  // Must return result — otherwise the tool's return value is lost
  return result
})

A common mistake to watch out for: forgetting return result. The tools/execute hook is a 'wrapping' hook — you can do work before and after, and optionally transform the result, but you must pass it back.

Step 5: Session Observation

Log a summary at the end of each Turn for later analysis:

// Listen to session/event and print a summary when each Turn ends
ctx.on('session/event', (session, event) => {
  if (event.type !== 'turn/end') return

  // reason.kind is why the Turn ended:
  // 'complete'    — normal completion (model decided the task is done)
  // 'error'       — something went wrong
  // 'interrupted' — user interrupted
  // 'max-turns'   — hit the max turn count limit
  const reason = event.data.reason.kind
  const turnNum = event.data.turn

  console.log(`[Turn ${turnNum}] ${reason}`)
})

This is the minimal version. A complete production implementation would also emit a telemetry record via ctx.sessionTelemetry.emit() and print a token consumption summary using ctx.tokenMeter.measure(session).

The Complete Plugin: Putting It All Together

All five parts combined into one file:

// packages/workspace-context/src/index.ts
// workspace-context plugin: workspace awareness + tool + permission interception + observability

import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'

// ── Plugin metadata ────────────────────────────────────────────
export const name = 'workspace-context'

// Declare service dependencies
// tools:        tool registry
// systemPrompt: system prompt section manager
export const inject = ['tools', 'systemPrompt']

// ── Plugin entry point ─────────────────────────────────────────
export function apply(ctx: Context): void {

  // ── 1. Inject a System Prompt Section ─────────────────────────
  ctx.systemPrompt.section({
    name: 'workspace-context:cwd',
    order: ctx.systemPrompt.getSectionOrder('context:workspace'),
    text: (context) => {
      const cwd = context.agent?.session?.header?.cwd
      if (!cwd) return ''
      return [
        '## Workspace',
        `Current working directory: ${cwd}`,
        'All relative file paths are resolved from this directory.',
      ].join('\n')
    },
  })

  // ── 2. Register the get_workspace_info tool ────────────────────
  ctx.tools.register(defineTool({
    name: 'get_workspace_info',
    description: 'Get information about the current workspace: directory listing and file sizes.',
    parameters: {
      path: {
        type: 'string',
        description: 'Relative path within the workspace. Defaults to workspace root.',
      },
    },
    output: {
      schema: {
        type: 'object',
        additionalProperties: false,
        properties: {
          path: { type: 'string' },
          entries: {
            type: 'array',
            items: {
              type: 'object',
              additionalProperties: false,
              properties: {
                name: { type: 'string' },
                type: { type: 'string', enum: ['file', 'directory'] },
                size: { type: 'number' },
              },
            },
          },
        },
      },
      render: (_args, value) => {
        const v = value as {
          path: string
          entries: Array<{ name: string; type: string; size: number }>
        }
        const lines = [`Directory: ${v.path}`, '']
        for (const entry of v.entries) {
          const sizeStr = entry.type === 'file' ? ` (${entry.size} bytes)` : '/'
          lines.push(`  ${entry.name}${sizeStr}`)
        }
        return [{ type: 'text', text: lines.join('\n') }]
      },
    },
    isConcurrencySafe: () => true,
    async execute(args, exec) {
      const cwd = exec.agent?.session?.header?.cwd ?? process.cwd()
      const targetPath = args.path ? join(cwd, args.path) : cwd
      const entries = await readdir(targetPath, { withFileTypes: true })
      const result = await Promise.all(
        entries.map(async (entry) => {
          const fullPath = join(targetPath, entry.name)
          const stats = entry.isFile() ? await stat(fullPath) : null
          return {
            name: entry.name,
            type: entry.isDirectory() ? 'directory' : 'file',
            size: stats?.size ?? 0,
          }
        })
      )
      return {
        path: targetPath,
        entries: result.sort((a, b) => {
          if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
          return a.name.localeCompare(b.name)
        }),
      }
    },
  }))

  // ── 3. Permission interception: require confirmation for dangerous tools ──
  ctx.on('tools/pre-execute', async (exec, next) => {
    const isDangerous = exec.name.includes('delete') || exec.name.includes('rm')
    if (!isDangerous) return next()
    return {
      kind: 'ask',
      reason: `About to run: ${exec.name}(${exec.arguments})`,
    }
  })

  // ── 4. Execution time metering ─────────────────────────────────
  ctx.on('tools/execute', async (exec, next) => {
    const start = performance.now()
    const result = await next()
    const elapsed = Math.round(performance.now() - start)
    console.log(`[${exec.name}] ${result.isError ? 'ERROR' : 'OK'} ${elapsed}ms`)
    return result
  })

  // ── 5. Session Turn summary observation ───────────────────────
  ctx.on('session/event', (session, event) => {
    if (event.type !== 'turn/end') return
    const reason = event.data.reason.kind
    const turnNum = event.data.turn
    console.log(`[Turn ${turnNum}] ${reason}`)
  })

  // ── Note ───────────────────────────────────────────────────────
  // No cleanup code anywhere in this file.
  // Every registration made through ctx (tools.register, systemPrompt.section, ctx.on)
  // is tracked by Cordis and automatically disposed when the plugin unloads.
}

The whole plugin is about 90 lines including comments. Five concerns, 10-20 lines each. This is roughly the right size for a typical dsh plugin.

Adding the Plugin to a Bundle

Once the plugin is written, it needs to be wired into a Bundle:

// bundle.ts (pseudocode: Bundle configuration file)
import workspaceContext from './packages/workspace-context/src/index.ts'

export default defineBundle([
  // Core service providers must be listed before the plugins that depend on them.
  // workspace-context declares inject: ['tools', 'systemPrompt'],
  // so both providers must appear before it.
  coreToolsPlugin,          // provides the tools service
  systemPromptPlugin,       // provides the systemPrompt service

  workspaceContext,         // our plugin

  // Other business plugins...
])

Cordis handles service readiness ordering based on inject declarations — even if the order in the Bundle is wrong, Cordis will wait for dependencies to be ready before activating the plugin, rather than crashing on startup.

Series Recap: Which Articles This Plugin Uses

This 90-line plugin covers most of the core mechanisms from the series:

Plugin feature Series article
ctx.tools.register() — register a tool Article 03: Tool System
defineTool + isConcurrencySafe — concurrency flag Article 03: Tool System
tools/pre-execute — permission interception Article 03: Tool System
tools/execute — wrap execution Article 03: Tool System
ctx.systemPrompt.section() — inject a prompt section Article 06: System Prompt Assembly
session/event — Turn-end observation Article 09: Observability
export const inject — Cordis service dependencies Article 02: Cordis Plugin System
ctx lifecycle auto-dispose Article 02: Cordis Plugin System
session.header.cwd — Session context Article 05: Session Memory

What this plugin doesn't use: multi-Agent collaboration (article 08) and capability seams (article 07). Those are higher-level mechanisms, typically configured at the framework level rather than touched by individual business plugins.

The dsh Design Philosophy

This is a good place to close out the series.

dsh's core design idea is simple: everything is a plugin, everything has a boundary.

You don't need to fork the core to change almost anything about behavior — adding tools, modifying prompts, intercepting dangerous operations, wiring up monitoring — all of that happens through plugins. Boundaries are enforced by Cordis's ctx: a plugin only affects what it registers, and when it unloads, it leaves no trace.

This pattern comes from Cordis. dsh applies it to an Agent runtime.

A production-grade Agent isn't just 'model + tool calls'. It needs permission controls, observability, persistence, multi-Agent coordination — it needs stable handles for all those engineering concerns. dsh's architecture is specifically designed to turn those problems into composable, swappable, testable pieces.

This is still an early field. Many best practices are still forming, many edge cases don't have standard answers yet. But with this architectural foundation, you at least know where to add code and where to look when something goes wrong.

Series Index

If you're just starting, work through these in order:

  • Article 01: What is dsh and why use it
  • Article 02: The Cordis plugin system — plugins, services, and lifecycles
  • Article 03: The tool system — registration, execution, interception
  • Article 04: The Agent loop — how a single conversation run works
  • Article 05: Session memory — how state is stored and read
  • Article 06: System prompt assembly — how prompts are constructed
  • Article 07: Capability seams — swappable Agent capabilities
  • Article 08: Multi-Agent collaboration — sub-Agents and task dispatch
  • Article 09: Observability — token metering and telemetry
  • Article 10: Complete plugin walkthrough (this article)

Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.

Find more on my Homepage

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.