Skip to content

Desktop Plugin API

This page is the API reference for trusted Desktop Plugins. To create and build your first plugin, start with Build a Desktop Plugin.

Use the four kernel primitives

The runtime has four primitives. Six contribution families provide concise shortcuts for common product integrations.

Own side effects

Use host.effect for work that has setup and cleanup but no natural React owner:

ts
host.effect(() => {
  const interval = window.setInterval(refreshBoard, 30_000);
  window.addEventListener("online", refreshBoard);

  return () => {
    window.clearInterval(interval);
    window.removeEventListener("online", refreshBoard);
  };
});

Effects, Host-owned subscriptions, and registrations made through the other primitives all belong to the active revision generation. Disable, uninstall, revision replacement, and Desktop shutdown clean them up together.

Compose UI surfaces

Use the three host.ui operations according to the change you need:

OperationComposition rule
addKeeps every active registration. The surface renders them together.
replaceUses the last active registration. Disposing it restores the previous replacement or Core default.
wrapWraps the current surface. A later wrapper is outside earlier wrappers.

Every call returns a disposable registration and is also generation-owned. Disposing an add removes only that item. Disposing a replace reveals the next active replacement. Disposing a wrap rebuilds the remaining wrapper chain.

An active replacement does not mount the replaced default component tree. Disposing it remounts the current fallback instead of revealing a hidden, still-running implementation.

“Last” and “later” mean actual registration order, including registrations from different plugins.

Use wrap when you need to preserve the current implementation while adding behavior or layout around it:

tsx
import type { DesktopPluginSurfaceWrapperProps } from "@dotcraft/plugin";

function ReviewFrame({
  children,
}: DesktopPluginSurfaceWrapperProps<"composer">) {
  return <section className="acme-board-review-frame">{children}</section>;
}

host.ui.wrap("composer", ReviewFrame);

Share services

Use renderer-local services when another plugin needs a callable contract rather than a visual surface:

ts
interface BoardService {
  openCard(id: string): void;
}

host.services.provide<BoardService>("acme-board.board", {
  openCard: (id) => openBoardCard(id),
});

const board = host.services.use<BoardService>("acme-board.board");
board?.openCard("DC-42");

use returns a synchronous snapshot of the last active provider; disposing it reveals the previous provider. Desktop modules may activate concurrently, so resolve cross-plugin services when an interaction needs them and handle undefined. Manifest dependencies require .NET and do not make a Desktop provider activate first. Renderer services do not cross into .NET, CLI, remote clients, or AppServer automatically.

Publish events

Use events for occurrence notifications that do not need a shared service reference:

ts
host.events.on<{ cardId: string }>("acme-board.card-opened", ({ cardId }) => {
  console.log("Opened", cardId);
});

host.events.emit("acme-board.card-opened", { cardId: "DC-42" });

Event listeners are removed with their generation. Events are renderer-local and do not persist Session data or become AppServer notifications.

Target Core surfaces

DotCraft's formal surfaces cover the application and Composer. Composer surfaces form a hierarchy, so you can target a complete region or one Core control:

SurfacePlacement
appThe complete rendered Desktop application.
app.backgroundAn empty decorative seat behind the application shell; Core's background remains inside app.
composerThe complete mounted Composer, including new-chat welcome, pre-thread embedded, and active-thread states.
composer.mascotThe 58 × 58 logical-pixel visual stage for the Composer mascot.
composer.beforeContent immediately before the Composer body.
composer.afterContent immediately after the Composer shell.
composer.inputThe complete attachment and rich-input region.
composer.toolbarThe complete control row inside the Composer card.
composer.toolbar.leadingThe leading command, permission, mode, and goal group.
composer.toolbar.trailingThe trailing context, model, voice, and submit group.
composer.statusThe workspace and subscription row below the Composer card.

Target these Core controls when a region is too broad:

RegionControl surfaces
Inputcomposer.input.attachments, composer.input.editor
Leading toolbarcomposer.toolbar.commands, composer.toolbar.permissions, composer.toolbar.mode, composer.toolbar.goal
Trailing toolbarcomposer.toolbar.context-usage, composer.toolbar.model, composer.toolbar.voice, composer.toolbar.submit
Statuscomposer.status.workspace, composer.status.subscription

Composer public surface hierarchy

Core supplies the normal component as each surface's default content. add renders after that content. Use wrap to render before it while preserving its behavior, or replace to remove it and take ownership of the replacement behavior:

tsx
import { Button } from "@dotcraft/plugin";
import type {
  DesktopPluginSurfaceProps,
  DesktopPluginSurfaceWrapperProps,
} from "@dotcraft/plugin";

function BeforeModel({ children }: DesktopPluginSurfaceWrapperProps<"composer.toolbar.model">) {
  return (
    <>
      <Button size="sm">Review model</Button>
      {children}
    </>
  );
}

function SubscriptionStatus(_: DesktopPluginSurfaceProps<"composer.status.subscription">) {
  return <span>Review ready</span>;
}

host.ui.wrap("composer.toolbar.model", BeforeModel);
host.ui.add("composer.status.subscription", SubscriptionStatus);

The same names mount in thread, Welcome, approval, and user-input Composers. A surface stays available when its Core default is hidden by the current provider, compact mode, minimal chrome, or decision state. Inspect the shared Composer context before rendering plugin content. A surface name and typed context are public contracts; its generated DOM is not.

Composer surface contexts have threadId: null whenever the Composer has not created or attached to a real Session thread, including welcome and detached embedded Composers. They carry the real thread id after attachment.

FieldMeaning
workspacePathCurrent workspace path, or null when unavailable.
threadIdAttached Session thread, or null before attachment.
modeCurrent agent or plan mode.
busyThe Composer is running, waiting, or performing maintenance.
awaitingApprovalA host approval decision is active.
variantdefault or an embedded Composer variant such as agentBuilder.
minimalChromeCore has hidden nonessential controls for an embedded Composer.

On the new-chat Welcome screen, composer covers the complete pre-thread composition experience: app selection, hero, input, workspace footer, and quick starts. Replacing it swaps that lifecycle as one unit.

Replace the Composer mascot

Replace composer.mascot with an image, SVG, canvas, Lottie player, or React character. This inline SVG example is self-contained and builds without a separate asset:

tsx
import type { DesktopPluginActivate, DesktopPluginSurfaceProps } from "@dotcraft/plugin";

function Mascot({ context }: DesktopPluginSurfaceProps<"composer.mascot">) {
  return (
    <svg
      viewBox="0 0 58 58"
      width={context.size}
      height={context.size}
      data-activity={context.activity}
      role="img"
      aria-label="Acme mascot"
    >
      <circle cx="29" cy="29" r="24" fill="var(--accent)" />
      <circle cx="21" cy="26" r="3" fill="currentColor" />
      <circle cx="37" cy="26" r="3" fill="currentColor" />
      <path d="M20 38 Q29 44 38 38" fill="none" stroke="currentColor" strokeWidth="3" />
    </svg>
  );
}

export const activate: DesktopPluginActivate = (host) => {
  host.ui.replace("composer.mascot", Mascot);
};

Core keeps the mascot's placement, bubble, menu, click handling, sleep timer, Composer handoff, and outer motion. The context inherits the normal Composer fields and adds activity, expression, light, size, submitRevision, reasoningEffort, speed, contextMax, and reducedMotion. React to snapshots directly; watch submitRevision when repeated submissions need a one-shot animation. Use host.events for plugin-defined occurrences.

ui.add("composer.mascot", ...) layers an accessory or effect over the same stage. Replace composer instead when the plugin also needs to own positioning or interaction behavior. composer.mascot does not change the Error Screen mascot or Agent Profile avatars.

Expose a plugin surface

Render PluginSurface inside your component to expose a plugin-owned extension point:

tsx
import { PluginSurface } from "@dotcraft/plugin";

declare module "@dotcraft/plugin" {
  interface DesktopPluginSurfaceContextMap {
    readonly "acme-board.card.footer": {
      readonly issueId: string;
    };
  }
}

function BoardCard() {
  return (
    <article className="acme-board-card">
      <h2>DC-42</h2>
      <PluginSurface name="acme-board.card.footer" context={{ issueId: "DC-42" }} />
    </article>
  );
}

Add the custom name to DesktopPluginSurfaceContextMap to type both the owner and every consumer. Provider and consumer packages should import one shared declaration module when they exchange a surface contract. Without declaration merging, an unknown surface receives unknown context. Another enabled plugin can target acme-board.card.footer with ui.add, ui.replace, or ui.wrap; activation order does not matter. Plugin-qualified names are recommended but not enforced.

The surface exists while its component is mounted. Registrations remain owned by their registering revisions and render whenever the surface is present.

Use convenience contributions

Return DesktopPluginActivation when one of the standard product integrations already matches the job:

FieldConvenience behavior
mainViewsAdds navigation, routing, and a full view.
settingsPagesAdds a page to Desktop Settings.
conversationViewsAdds a thread-scoped tab beside Chat.
commandsAdds a searchable command with availability and execution.
toolRenderersRenders an exact presentationId with Core and generic fallbacks.
messageActionsAdds an action to the standard assistant-message action area.

These six fields are convenience APIs, not an allowlist or capability ceiling. Add Composer UI with host.ui.add("composer.toolbar.leading", ...); use surfaces, services, events, and effects when your feature does not fit the convenience fields.

The returned activation may also provide dispose(). Contribution ids must be unique in that activation. Use label.translations for localized labels and order only where the convenience API defines ordered placement.

Use the Host API

Beyond the four primitives, DesktopPluginHost groups stable product operations by owner:

MemberUse it for
plugin, environmentPlugin identity plus locale, theme, platform, and workspace context.
navigationViews, settings, threads, external URLs, and user notifications.
appServerSupported JSON-RPC requests and subscriptions.
appBindings, appSurfacesConnected-app binding and app-provided UI surfaces.
workspacesReading local workspace information.
oratorioTeam runs, handoffs, and events.

Import shared UI components from @dotcraft/plugin; the official builder connects hooks and JSX to Desktop's React runtime.

The Host API is a compatibility contract, not an access-control boundary. Main-process URL, route, bearer, size, and timeout checks remain service invariants rather than plugin permissions.

Use DOM and CSS deliberately

Desktop Plugins may access the renderer DOM and load global CSS. DotCraft does not block those techniques, but its DOM structure, class names, private CSS variables, stores, and feature components are not public contracts. Prefer a public surface or service when one exists; direct DOM or CSS carries its own maintenance cost.

Keep UI and backend responsibilities separate

A custom background, Composer decoration, wrapper, plugin surface, renderer service, or renderer event needs only a Desktop Plugin. DotCraft does not create matching C# or AppServer APIs for pure UI.

Add a .NET plugin or AppServer contract when the feature needs backend execution, durable host-owned state, Agent tools or hooks, another client, or cross-process and remote coordination. One plugin bundle may contain both modules, but neither is required solely because the other exists.

Generation and reload lifecycle

See DotNetPluginSample for two runnable .NET and Desktop bundles. The Core plugin replaces the background, wraps the app, and owns a renderer service, event, and custom surface; the Consumer adds Composer controls and contributes UI to the Core-owned surface.

Build before DotCraft discovers or packages the plugin. Refresh the plugin list, then enable it. Desktop activates the whole content revision as one generation. An unchanged revision is a no-op; an updated revision disposes the previous generation and activates the new one.

Disabling or replacing a revision withdraws Host-owned registrations immediately. Desktop does not wait for an unfinished plugin activate() or dispose() promise before continuing, and a late activation result cannot publish.

The revision is the development iteration unit. Desktop Plugins do not have a built-in file watcher, HMR, component-only reload, or partial-generation update. Rebuild, then refresh or re-enable the plugin.

Desktop never loads executable plugin code from a remote AppServer. With a remote workspace, it activates only locally packaged code whose plugin id, version, and Desktop content revision match the remote snapshot.