Downcity
Plugins

Custom Plugin

Build a custom plugin with config, actions, hooks, system text, availability checks, and optional runtime HTTP routes

Custom Plugin

If the thing you want to add is mainly:

  • a set of actions
  • a set of hooks
  • one system text layer
  • one setup or usage protocol
  • one set of runtime HTTP routes

then a custom plugin is usually the right extension point.

Current public entry points

@downcity/agent now exposes all of these:

  • plugin types and contracts
  • built-in plugin exports
  • the local Agent plugins: [...] assembly entry point

That means custom plugins are no longer only conceptual shapes. You can attach them directly to a local SDK Agent.

Minimal skeleton

import type { Plugin } from "@downcity/agent";

export const notesPlugin: Plugin = {
  name: "notes",
  title: "Notes Helper",
  description: "Adds note-related actions and prompt guidance.",
  actions: {
    status: {
      execute: async ({ context }) => {
        return {
          success: true,
          data: {
            rootPath: context.rootPath,
          },
        };
      },
    },
  },
};

Attach the custom plugin to a local Agent

import { Agent, type Plugin } from "@downcity/agent";

const notesPlugin: Plugin = {
  name: "notes",
  title: "Notes Helper",
  description: "Adds note-related actions and runtime guidance.",
  actions: {
    status: {
      allowWhenDisabled: true,
      execute: async ({ context }) => ({
        success: true,
        data: {
          rootPath: context.rootPath,
        },
      }),
    },
  },
};

const agent = new Agent({
  id: "repo-helper",
  path: "/path/to/project",
  tools: {},
  plugins: [notesPlugin],
});

const result = await agent.plugins.runAction({
  plugin: "notes",
  action: "status",
});

A fuller example

import type { Plugin } from "@downcity/agent";

export const notesPlugin: Plugin = {
  name: "notes",
  title: "Notes Helper",
  description: "Adds note-related actions and runtime guidance.",
  config: {
    plugin: "notes",
    scope: "project",
    defaultValue: {
      injectPrompt: true,
      folder: ".notes",
    },
  },
  availability(context) {
    return {
      enabled: true,
      available: true,
      reasons: [],
    };
  },
  system(context) {
    return `Current project notes folder: ${context.rootPath}/.notes`;
  },
  actions: {
    status: {
      allowWhenDisabled: true,
      execute: async ({ context }) => {
        return {
          success: true,
          data: {
            rootPath: context.rootPath,
          },
        };
      },
    },
  },
  hooks: {
    effect: {
      "notes.observe": [
        async ({ value }) => {
          console.log(value);
        },
      ],
    },
  },
};

The most important design questions

1. What exactly are you extending

Be explicit about whether you need:

  • actions
  • hooks
  • resolve points
  • system text
  • HTTP

Do not default to implementing every field.

2. Is this actually a service instead

If you find yourself needing:

  • long-lived workers
  • session pools
  • durable runtime instances
  • long-lived connections

then you should pause and ask whether the core should really be a service instead.

3. Should some actions run while disabled

Actions like:

  • status
  • install
  • configure

often need allowWhenDisabled: true.

One practical rule

Your plugin execute logic should rely on the minimal stable command-context shape when possible instead of assuming access to every possible runtime singleton.

That keeps the plugin easier to reuse across:

  • CLI
  • Console
  • runtime invocation paths

When to split plugin code into modules

Once a plugin grows, the healthier structure is usually:

  • Plugin.ts
  • Config.ts
  • Command.ts
  • Dependency.ts
  • runtime/*
  • types/*

That keeps Plugin.ts closer to declaration and assembly instead of turning it into a giant logic file.

Current SDK boundaries

The local SDK now supports a real plugin registry, but a few boundaries still matter:

  • the SDK does not auto-register every built-in plugin
  • whether a plugin can run is still affected by city-level lifecycle enablement
  • the local SDK session prompt path injects plugin.system() for plugins explicitly registered on that SDK Agent
  • plugin.http.runtime depends on the full runtime HTTP assembly path, not the minimal SDK HTTP server

If you want real scenario-driven examples

Continue with: