DSH Plugin Development

August 15, 2026 · View on GitHub

Treat a DSH plugin as a dynamically mounted capability, not as a static module import. Correctness has five independent dimensions:

  1. capability graph — Cordis can activate, suspend, reload, and dispose it;
  2. Tool contract — Code Mode receives a lossless typed value;
  3. projections — the model, UI, and durable log receive purpose-built views;
  4. product surface — the user sees a decision rather than implementation data;
  5. installation closure — a clean profile resolves built host and client code.

Most hard DSH failures are mismatches between these dimensions. Diagnose the boundary before changing the symptom.

Inspect the installed architecture first

Locate the actual DSH version and a working plugin from the same version. APIs and UI seams can move between releases.

rg -n 'defineTool|presentationMeta|presentResult|code-dispatch|tool.details|dsh\.client|dsh\.bundle' \
  <dsh-checkout> <plugin-root>
dsh --profile <profile> --dump-config

Establish:

  • the provider and consumer of every injected service;
  • the plugin's activation state and disposer path;
  • the canonical output schema, renderer, and UI presenter of every Tool;
  • whether calls are top-level or nested inside Code Mode;
  • which session event makes an outcome durable and replayable;
  • the owner props and keyed slot used by the mounted UI;
  • the profile layers, built exports, and runtime peer resolution;
  • the real upstream API scope, pagination, and error contract.

Why: a locally plausible implementation may stay pending, disappear on replay, or fail only when installed outside the source workspace.

Model Cordis as a live capability graph

A plugin provides apply(ctx) and may declare inject. ctx is a live graph, not a service locator initialized once at boot.

export const inject = ['tools']

export function apply(ctx: Context) {
  const dispose = ctx.effect(
    () => ctx.tools.register(tool),
    'register example tool',
  )

  ctx.inject(['webServer'], httpCtx => {
    httpCtx.effect(() => httpCtx.webServer.register(route), 'register route')
  })

  return async () => dispose()
}
  • inject declares a capability requirement. The plugin may remain pending until the provider appears; YAML order alone does not prove activation.
  • Register routes, listeners, Tools, and child plugins through reversible effect scopes. Unload, failed activation, and HMR should restore the previous graph.
  • Dynamically inject optional services. A headless Tool plugin should not fail merely because a browser server is absent.
  • Identify the event mode. Around/waterfall hooks call next() unless replacing downstream behavior intentionally.
  • Preserve stable identity for configuration rows so reconciliation can update instead of destroy-and-recreate.

Why: DSH can change its provider graph while the process and session remain alive. Startup-only assumptions produce leaks, invisible pending plugins, and HMR behavior that differs from a clean boot.

Design one result and four projections

Do not call every representation “the Tool result”. Write this table before implementation:

RepresentationConsumerRequired property
canonical valueCode Mode and programslossless, typed JSON
rendered contentmodel contextbounded and task-relevant
presentationMetatop-level live/replayed UIpure, replayable projection
durable nested projectionCode Mode replay/UIexplicit, bounded session event

Return exactly one canonical value satisfying output.schema. Do not return UI blocks or explanatory prose as the canonical value.

defineTool({
  name: 'example_plan',
  parameters: inputSchema,
  output: {
    schema: outputSchema,
    render: (_args, value) => [{
      type: 'text',
      text: JSON.stringify(toModelProjection(value)),
    }],
  },
  execute: async args => buildCanonicalResult(args),
  presentationMeta: (_args, value) => toUiProjection(value),
})
  • Treat output.schema as a programmatic API and validate real representative responses against it.
  • Keep render bounded. It determines what the model reads; it is not the canonical storage format.
  • Keep presentation functions pure: no network, clock, randomness, mutation, or hidden process state. They also run during replay.
  • Bound UI metadata independently from canonical data. Details can fetch pages.
  • Assume nested Code Mode calls may skip the top-level presentation path. If the outcome must survive replay or drive UI, project it explicitly through the Code-dispatch logging seam.
  • Change generic spill/truncation only after identifying which representation crosses that boundary. A bounded business result often solves the problem before storage does.

Why: programs, model context, session history, and visual presentation have different budgets and lifetimes. One shared JSON/text payload creates schema errors, context growth, replay loss, and blank views.

Keep atomic Tools, add one business operation

Atomic Tools are useful for composition and diagnosis. They should not force the model to become a pagination engine or distributed query planner.

For workflows over many records:

  1. keep bounded atomic reads/updates with explicit filters and cursors;
  2. put exact aggregation next to authoritative data and tenant filtering;
  3. add a high-level Tool owning pagination, joins, limits, capacity, and fallback;
  4. return business entities, ordered decisions, and gaps—not intermediate cells;
  5. separate preview from mutation for inventory, money, publishing, or claims.

The model chooses intent and constraints. The high-level Tool executes the data plan. The UI visualizes the decision.

Why: model-driven full scans are expensive and easy to leave incomplete. They also leak infrastructure language into the user's task.

Audit upstream contracts from three sources

Before writing a strict Tool schema, compare:

  1. the current service implementation and response model;
  2. read-only deployed behavior when deployment drift matters;
  3. the consuming product's real ownership and scope semantics.

Resolve disagreements explicitly. Do not infer a production API from a CLI catalog or a neighboring repository type. Credential reuse and capability transport are separate: a plugin can reuse an existing credential store while calling a typed HTTP API directly.

For large lists, require stable pagination metadata. For summaries, prefer a server aggregate whose totals do not change with the display page. Fallback only on a specific compatibility signal; propagate authorization, validation, and server failures.

Why: strict schemas expose drift early only when they describe the real boundary.

Build UI from the user's decision

Do not default to a generic object inspector. Start with the question the user must answer after the Agent finishes.

  • The chat card states outcome, scope, and important exceptions.
  • Details show the smallest visual object needed to inspect or adjust the result.
  • Hide internal Tool names, JSON fields, IDs, pagination, and technical statuses unless the user is debugging.
  • Use server paging/search and bounded DOM rendering for large collections.
  • Provide a browser-authorized media path, not merely a server-side URL string.
  • Do not let parallel Tool results compete to auto-open one details panel. Use an explicit selection action when multiple results can settle out of order.

Verify a keyed details slot through the mounted chain:

card click
  → actual ToolCall owner
  → selection / openDetails
  → shared details layout
  → keyed renderer
  → useful plugin view or generic fallback

Pre-session defaults and per-session pinned state have different lifetimes. Put their controls in the corresponding new-conversation and session-header seams.

Why: an isolated component can render correctly while the real shell never passes it a selected owner. DSH UI correctness is composition correctness.

Make replay an execution mode

The durable session log reconstructs model context and UI. “Visible now” does not mean “visible after reload”. Test:

  1. a direct top-level Tool call;
  2. the same Tool nested inside Code Mode;
  3. reload/replay after completion;
  4. unload/reload or HMR;
  5. interrupted and failed calls.

Persist bounded business projections without credentials. Replay must not depend on the original network response, in-memory value, clock, or component state.

Why: top-level and nested calls travel through different presentation paths, and replay has access only to durable events.

Package as if the monorepo does not exist

Host and browser client are separate runtime halves:

  • host main/exports point to built Node output;
  • ./client points to built browser output when dsh.client is declared;
  • the bundle patch inserts the host plugin in the intended tree;
  • client injection lists runtime/UI dependencies;
  • peers resolve from the installed plugin/profile, not a source checkout;
  • production starts the built DSH CLI rather than a source loader;
  • --dump-config proves the plugin is mounted where expected.

Run the bundled checker:

node <skill-root>/scripts/check-plugin-closure.mjs <plugin-root>

Then pack/install the plugin into a clean temporary profile and boot it without the monorepo's node_modules on the resolution path.

Why: workspaces hide undeclared peers, missing browser builds, wrong archive roots, and source entrypoints that cannot resolve in production.

Verification ladder

  1. Typecheck and validate canonical schemas against representative responses.
  2. Activate and unload under Cordis; verify every registration disappears.
  3. Verify headless activation and later arrival of optional providers.
  4. Exercise top-level, Code Mode, replay, failure, and large-result projections.
  5. Exercise browser click → details → useful non-empty visual output.
  6. Verify bounded DOM, paging/search, media, empty/error states, and scope lifetime.
  7. Boot a clean built profile and inspect --dump-config.
  8. Then verify reverse proxy, authentication boundary, persistent DSH home, and restart behavior in deployment.

Do not claim success from unit tests, SSR, or one direct Tool call alone. Report which execution modes and installation boundaries were actually exercised.

Diagnose by boundary

Read references/failure-atlas.md when symptoms are confusing. It maps field failures to their incorrect mental model and the evidence that distinguishes them.

Handoff

Report:

  • capability graph and optional dependencies;
  • canonical/model/UI/durable projections;
  • upstream scope and pagination evidence;
  • atomic Tools versus the high-level business operation;
  • direct, Code Mode, replay, and mounted-UI results;
  • clean-profile package result and dumped plugin tree;
  • remaining host seam or deployment prerequisite.