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
Agentplugins: [...]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:
statusinstallconfigure
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.tsConfig.tsCommand.tsDependency.tsruntime/*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.runtimedepends on the full runtime HTTP assembly path, not the minimal SDK HTTP server
If you want real scenario-driven examples
Continue with:
- NotesPlugin scenario
- ReviewGuardPlugin scenario
- MessageAugmentPlugin scenario
- RoleResolverPlugin scenario
- SnapshotHttpPlugin scenario
- Plugin plus service composition