Plugin Development Guide
Build a Downcity Plugin from its manifest and profile to its Agent runtime context
Plugin Development Guide
This guide covers the complete Downcity Plugin model. A Plugin is an independent capability unit with its own configuration, actions, state, and lifecycle. Runtime capabilities are exposed by the Agent through the smallest required PluginContext interfaces.
Design principles
- The Plugin constructor accepts its own
profile, not anAgent,City,Embassy, ordependenciesbag. - The Plugin creates and owns its providers, clients, stores, queues, and resource handles.
- Workspace capabilities are exposed through the runtime context; external services such as image or speech AI are injected into the Plugin constructor as narrow interfaces.
- A profile contains persistent, validated values. Runtime objects belong to the Plugin instance and never belong in configuration files.
- Keep one clear domain capability per Plugin. When an implementation needs a different boundary, create another Plugin instead of adding more composition switches.
Directory layout
example-plugin/
├── plugin.json
├── package.json
├── README.md
├── icon.svg
└── plugin.jsplugin.js is the self-contained ESM entry in the installation artifact; its filename is not fixed as long as it matches plugin.json's entry. Source files and build configuration may remain in the source repository, but an installed directory must contain plugin.json, package.json, README.md, and the entry file.
plugin.json
The manifest describes installation, presentation, and the profile schema. It does not describe runtime dependencies:
{
"schema_version": 1,
"id": "example",
"version": "1.0.0",
"title": "Example",
"description": "A small example Plugin.",
"icon": "icon.svg",
"entry": "plugin.js",
"source": "github:owner/example-plugin",
"config": {
"schema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["local", "remote"],
"default": "local"
}
},
"additionalProperties": false
},
"defaults": {
"provider": "local"
}
}
}config.schema drives CLI/Desktop forms and startup validation. config.defaults is the complete object passed to the constructor when no profile is selected. A field-level Schema default is primarily a form hint and does not replace defaults.
Profiles and runtime state
A profile contains values that users can save, copy, and audit:
export interface ExampleProfile {
/** Selects the implementation used by the Plugin. */
provider?: "local" | "remote";
/** Remote service endpoint. */
endpoint?: string;
}Runtime objects are created inside the Plugin:
export class ExamplePlugin extends BasePlugin {
readonly name = "example";
private readonly client: ExampleClient;
constructor(profile: ExampleProfile = {}) {
super();
this.client = new ExampleClient({
provider: profile.provider ?? "local",
endpoint: profile.endpoint,
});
}
}Do not put browser instances, database connections, queues, callbacks, Agent instances, or City/Embassy instances in a profile.
Plugin class and entry point
import { BasePlugin, create_action } from "@downcity/agent";
import { z } from "zod";
export class ExamplePlugin extends BasePlugin {
readonly name = "example";
readonly title = "Example";
readonly description = "An example Plugin.";
constructor(profile: ExampleProfile = {}) {
super();
// Validate the profile and create the Plugin-owned runtime dependencies here.
}
readonly actions = {
status: create_action({
description: "Return the current Plugin status.",
input_schema: z.object({}),
execute: async ({ context, execution }) => ({
success: true,
data: {
agent_id: context.agent_id,
workspace_id: context.workspace_id,
call_id: execution.call_id,
},
}),
}),
};
}Third-party entries must export plugin(profile):
import type { JsonObject, Plugin } from "@downcity/agent";
export function plugin(profile: JsonObject): Plugin {
return new ExamplePlugin(profile as ExampleProfile);
}The loader validates the profile with the manifest Schema and creates an isolated instance for each Agent.
PluginContext
Actions, system text, and lifecycle hooks can use PluginContext:
interface PluginContext {
agent_id: string;
workspace_id: string;
workspace_path: string;
data_path: string;
files: FileSystem;
data_files: FileSystem;
logger: Logger;
ai?: PluginAiServices;
web?: PluginWebServices;
sessions: AgentSessions;
plugins: AgentPlugins;
workspace_env: Readonly<Record<string, string>>;
instructions: readonly string[];
}Use files for user Workspace data and data_files for Agent-private persistent data. Do not write private databases, caches, or credentials into the user Workspace.
Using Agent capabilities
The host provides services when constructing the Agent:
const agent = new Agent({
id: "assistant",
plugins: [new ExamplePlugin({ provider: "local" })],
});A Plugin reads only the smallest interface it needs:
const models = await image_ai.catalog();
if (!models) return { success: false, error: "AI service is unavailable" };This keeps the same Plugin usable in CLI, Desktop, and embedded hosts without depending on a host's complete object graph.
Actions
Every action should have an explicit input Schema, a stable result, and cancellable asynchronous execution:
run: create_action({
input_schema: z.object({ value: z.string().min(1) }),
execute: async ({ input, execution }) => {
execution.abort_signal.throwIfAborted();
const result = await client.run(input.value, {
signal: execution.abort_signal,
});
return { success: true, data: result };
},
})Long-running work must respond to execution.abort_signal and pass the AbortSignal to external requests. Timeout and cancellation are controlled by the caller; a Plugin should not block the Agent itself.
Lifecycle
Only the Plugin creates and releases its own lifecycle resources:
readonly lifecycle = {
start: async (context: PluginContext) => {
await this.client.initialize({ agent_id: context.agent_id });
},
enter_workspace: async (context: PluginContext) => {
await this.client.select_workspace(context.workspace_path);
},
leave_workspace: async () => {
await this.client.clear_workspace();
},
stop: async () => {
await this.client.dispose();
},
};The Agent starts a Plugin after registration and calls stop when it is unloaded or stopped. stop should be idempotent and release browsers, connections, timers, queues, and every other long-lived resource.
Configuration and assembly
A local host may save multiple profiles:
schema_version = 1
[profiles.default]
provider = "local"
[profiles.remote]
provider = "remote"
endpoint = "https://example.com"After an Agent binds a profile name, the loader reads and validates it, then executes:
registration.create(profile)Built-in and third-party Plugins use the same path. The host owns the configuration source and credentials; the Plugin receives the resolved profile only.
Data boundaries
- User-created and user-edited files:
context.files. - Plugin-private indexes, caches, and state:
context.data_files. - Workspace-scoped resources:
enter_workspace/leave_workspace. - Agent-scoped resources:
start/stop.
A Plugin must not modify another Plugin's private directory or retain the host's complete configuration object.
Testing
Test profiles, actions, missing services, cancellation, and lifecycle behavior:
it("uses the profile to select the provider", async () => {
const plugin = new ExamplePlugin({ provider: "local" });
expect(plugin.name).toBe("example");
});
it("returns a stable error when AI is unavailable", async () => {
const agent = new Agent({ id: "test", plugins: [new ExamplePlugin()] });
const result = await agent.plugins.call("example", "models", {});
expect(result.success).toBe(false);
});Do not pass a generic AI object through Agent. Inject only the narrow service required by the Plugin and use a test implementation of that service in Plugin tests.
Release checklist
plugin.jsonhas the correctid,version,entry, andconfig.schema.- The entry exports
plugin(profile)and creates a new instance for every call. - Profiles contain no connections, instances, callbacks, or host objects.
- Every long-lived resource is released by
stop. - Action inputs are schema-validated and asynchronous work responds to cancellation.
- Private data uses
data_files; user data usesfiles. - CLI, Desktop, and embedded hosts can construct the Plugin from the same profile.