A step-by-step guide to shipping a plugin for the AI Code Assistant. It assumes
no prior knowledge of the plugin system; reading
plugins.md afterwards gives the full architecture (manifest
validation, capabilities, the event dispatcher, audit, and error reporting).
By the end you will have a working plugin installed and enabled in a workspace,
in well under 15 minutes. The complete example lives at
examples/plugins/hello_world/.
- The repository checked out and dependencies installed
(
pip install -r requirements-dev.txt). - A configured app (
cp .env.example .env, thenflask --app wsgi init-db). - A workspace you own (plugins are enabled per workspace).
Create your plugin's own directory. The only required file is manifest.json;
put the code next to it.
my_plugin/
├── manifest.json # required: identity, entry point, capabilities
├── plugin.py # your plugin class
└── README.md # optional
Discovery is local-only: an operator points flask plugins install at a
filesystem path. There is no remote/URL installation and the management API
never loads or executes plugin code by itself.
manifest.json is validated against
plugins/plugin.schema.json and by
PluginManifest.from_dict. A minimal manifest:
{
"id": "hello-world",
"name": "Hello World",
"version": "0.1.0",
"description": "Minimal example plugin that records project.created events.",
"author": "Your Name",
"entry_point": "hello_world.plugin:HelloWorldPlugin",
"compatibility": ">=0.1.0",
"capabilities": ["PROJECT_READ"],
"configuration": {
"greeting": "Hello from the hello-world plugin"
}
}Field rules (all required unless noted):
| Field | Rules |
|---|---|
id |
^[a-z][a-z0-9_-]*$, unique. The id is taken from the manifest — a client can never override it. |
name / description / author |
Non-empty human-readable strings. |
version |
Semantic version (0.1.0, 1.0.0-alpha, …). |
entry_point |
module.path:ClassName (the class is instantiated with app/manifest kwargs). |
capabilities |
Non-empty list of known capabilities (see step 5). Declared ≠ granted. |
compatibility |
Optional PEP 440 specifier (>=0.8.0, >=0.1.0,<0.5.0). Omit for any version; an incompatible range is refused at install. |
configuration |
Optional default config object, seeded into each workspace installation. Never put secrets in the manifest. |
Unknown capabilities, a bad id/version/entry point, or an invalid compatibility
range raise ManifestValidationError and the install is rejected.
The entry_point names a class. It may implement an optional event handler and
the lifecycle hooks on_enable / on_disable / on_uninstall:
from app.services.events import get_dispatcher
PLUGIN_ID = "hello-world"
EVENT_TYPE = "project.created"
class HelloWorldPlugin:
def __init__(self, app=None, manifest=None):
self.app = app
self.manifest = manifest
def on_event(self, event):
# Keep handlers cheap and non-raising; return safe data if useful.
return {"event": event.event_type, "project_id": (event.data or {}).get("project_id")}
def on_enable(self):
get_dispatcher().subscribe(EVENT_TYPE, self.on_event, plugin_id=PLUGIN_ID)
def on_disable(self):
get_dispatcher().unsubscribe(EVENT_TYPE, self.on_event)Key points:
- Subscribe with your
plugin_id. That is what makes dispatch-time capability enforcement apply (and what the audit log records). Subscribing without aplugin_idmarks the handler as trusted internal code. - Only subscribe to supported events.
subscriberaisesEventErrorfor an unknown event type; the supported list is inSUPPORTED_EVENTS(app/services/events.py) and summarised inplugins.md. - Never let a handler raise. Handler failures are isolated (the request is never crashed) and recorded as a bounded error report, but a plugin that silently fails helps nobody.
- Hooks are isolated too. An absent hook is a no-op and a raising hook does not prevent the enable/disable state change.
Install the plugin (operator scope — registers the global Plugin row, never
grants capabilities, never executes code):
flask plugins install examples/plugins/hello_world
flask plugins inspect hello-worldEnable it per workspace through the management API or UI (owner only):
# List the workspaces you can access
curl -s "$BASE/plugins/api/workspaces" -H "Cookie: session=…"
# Install the manifest into a workspace (owner only)
curl -s -X POST "$BASE/plugins/api/workspaces/$WS/plugins/install" \
-H "Content-Type: application/json" \
-d '{"manifest": { ...same manifest object... }}'
# Enable the workspace installation
curl -s -X POST "$BASE/plugins/api/workspaces/$WS/plugins/hello-world/enable"The management UI at /plugins/ provides the same actions. Non-members get
404; members who are not the owner get 403 for management operations.
Capabilities are never granted implicitly. The manifest declares what the plugin needs; a workspace owner must grant each capability for the workspace. Granting a capability the manifest did not declare is rejected.
curl -s -X POST "$BASE/plugins/api/workspaces/$WS/plugins/hello-world/capabilities" \
-H "Content-Type: application/json" \
-d '{"grant": ["PROJECT_READ"], "revoke": []}'Until the grant exists, the plugin is installed and enabled but its handlers are
denied at dispatch time (fail closed) and the denial is recorded in the
audit log. The capability list is in app/services/capabilities.py.
flask plugins list/flask plugins inspect hello-world— registration and enabled state.GET /plugins/api/workspaces/<ws>/plugins— per-workspace installation state.- Trigger a real event (for
hello-world, import/create a project) and confirm the handler ran. flask plugins errorsorGET /plugins/api/workspaces/<ws>/plugin-errors— bounded error reports if a handler or hook raised.- The workspace audit view (
GET /workspaces/api/workspaces/<id>/audit) records grant/revoke, enable/disable, and dispatch denials.
- Capability not granted. The most common cause of "my handler never runs". Install + enable is not enough; grant the declared capability.
- Wrong
entry_point. It must be an importablemodule.path:ClassName. For local development, put the plugin's parent directory onPYTHONPATH(export PYTHONPATH="$PWD/examples/plugins:$PYTHONPATH"). - Subscribing without
plugin_id. This bypasses capability enforcement and should only be used by trusted application code, not plugins. - Unsupported event type.
subscriberaisesEventError; checkSUPPORTED_EVENTS. - Secrets in config. Workspace config may hold secrets, but it is owner-only and omitted from list/inspect surfaces. Never commit secrets to the manifest.
- Expecting auto-discovery. There is none at startup; installs are explicit and local.
The example plugin ties it all together:
- Read
examples/plugins/hello_world/manifest.json— idhello-world, entry pointhello_world.plugin:HelloWorldPlugin, declaredPROJECT_READ. - Read
examples/plugins/hello_world/plugin.py—HelloWorldPluginsubscribes toproject.createdinon_enableand records the project id. - Register and enable it (steps 4–5), grant
PROJECT_READ, then create a project in that workspace. The handler runs and returns the project id.
tests/test_plugin_example.py validates the example manifest and class so the
guide cannot drift from a working plugin.
Plugins may one day need to make outbound HTTP requests. All such requests must
go through the shared guard in
app/services/plugin_network.py — never
call requests directly. The guard is https-only, denies private /
loopback / link-local / reserved targets and obviously-private hostnames by
default, and only reaches hosts on the operator's allowlist.
from app.services.plugin_network import guarded_get
def on_event(self, event):
# Raises PluginNetworkError unless the configured policy allows the host.
response = guarded_get("https://api.example.com/v1/status")
return response.json()The policy is configuration, not code (see .env.example):
| Variable | Default | Meaning |
|---|---|---|
PLUGIN_NETWORK_ALLOWLIST |
empty | Comma-separated hosts. * allows any public host, *.example.com allows subdomains. Empty denies every plugin request (fail closed). |
PLUGIN_NETWORK_HTTPS_ONLY |
1 |
Reject anything that is not https. |
PLUGIN_NETWORK_ALLOW_PRIVATE |
0 |
Development escape hatch; keep off in production. |
PLUGIN_NETWORK_TIMEOUT |
15 |
Request timeout in seconds. |
PLUGIN_NETWORK_MAX_BYTES |
2097152 |
Cap on a response body size. |
PLUGIN_NETWORK_STRICT_DNS |
0 |
When set, an unresolvable host is rejected instead of tolerated. |
Use guard_plugin_url / is_plugin_url_allowed to validate a target without
sending a request. The full policy and threat model are recorded in
security.md.
plugins.md— full architecture and management API reference.plugins/plugin.schema.json— JSON Schema.../plugins.md/security.md— the security model (capabilities, fail-closed dispatch, error isolation).