Plugin Development

Build a unified City Plugin module with @downcity/city/plugin.

Plugin Development

Plugin author APIs live in @downcity/city/plugin. A Plugin is a long-lived instance owned by City; do not create one instance per Agent.

Execution instance

import { Plugin, create_action } from "@downcity/city/plugin";
import { z } from "zod";

class ExamplePlugin extends Plugin {
  readonly name = "example";
  readonly title = "Example";
  readonly description = "An example Plugin.";

  constructor(private readonly client: ExampleClient) {
    super();
  }

  readonly actions = {
    status: create_action({
      description: "Return the current scope.",
      input_schema: { zod: z.object({}) },
      execute: async ({ context, execution }) => ({
        success: true,
        data: {
          agent_id: context.agent.id,
          workspace_id: context.workspace.id,
          session_id: context.session?.id ?? null,
          call_id: execution.call_id,
        },
      }),
    }),
  };
}

Use context.workspace.files for user Workspace files and context.storage.files for private Plugin state. context.session and context.agent.sessions are directly callable restricted handles; a Plugin does not need to resolve objects from IDs alone.

Unified City module

export default new ExamplePlugin();

An SDK host places the instance in City and adds Agents:

const city = new City({ plugins: [new ExamplePlugin()], workspaces: [workspace] });
city.agents.add(agent);

Lifecycle

initialize/dispose belong to City's unique Plugin instance. Agent and Workspace are dynamic call context, not lifecycle boundaries. Key long-lived resources by their actual domain owner and close all of them in dispose. Every asynchronous Action should propagate execution.abort_signal.

Installable package

example-plugin/
├── plugin.json
├── package.json
├── README.md
├── dist/main.js
└── dist/renderer.js
{
  "schema_version": 1,
  "id": "example",
  "version": "1.0.0",
  "description": "A small example Plugin.",
  "readme": "./README.md",
  "main": "./dist/main.js",
  "renderer": {
    "entry": "./dist/renderer.js",
    "sidebar": true,
    "mainview": true,
    "config": false
  }
}

main default-exports a Plugin instance or { plugin, main }; there is no separate agent entry. A Renderer communicates only through the host action gateway and cannot access Node, Electron, Agent objects, or Plugin Config files directly.

Testing focus

  • Multiple Agents using one City Plugin initialize one instance and read the same configuration.
  • Repeated calls receive fresh Context objects with the correct Agent and Workspace.
  • dispose waits for the final execution lease.
  • Concurrent main calls activate once, and shutdown fully deactivates it.
  • Action input, JSON results, and cancellation signals remain valid.