| title | Plugins and the registry |
|---|---|
| description | How a plugin is assembled rather than constructed, and every way mount() refuses. |
| group | How it works |
| order | 3 |
A plugin is a package exporting one object:
export const plugin: Plugin = {
name: "@amykit/plugin-file-queue",
version: "0.1.0",
configSchema,
register(registry, ctx) {
registry.queue(new FileQueue(path.join(ctx.paths.state, ctx.config.directory as string)));
},
};That is the entire contract. Everything below is what the host does with it.
Plugins are loaded from the config and assembled, not built by the CLI:
const loaded = await load(specs); // import each by name
const outcome = await mount(loaded.plugins, config, host);load imports each spec — a package name, or a path — and takes its plugin
export. Nothing is compiled in, which is what lets an install carry a plugin
this repository has never heard of, and lets a machine skip the ones it has no
use for.
A package that is not there is told apart from a plugin that threw, and answered once with the list of what is installed:
amy could not start:
@acme/plugin-jira: not installed — install it, or drop it from the config
Installed: @amykit/plugin-claude, @amykit/plugin-file-queue, …
All of these happen at boot, by name, before any work is touched. That is the point of assembling rather than constructing: the alternative is finding out halfway through somebody's ticket.
| Refusal | Message |
|---|---|
| A plugin will not import | not installed — install it, or drop it from the config |
| A plugin imported but exports nothing | imported, but exports no \plugin`` |
| A plugin threw while registering | failed to mount — <what it said> |
| A setting is the wrong type | `retentionDays` must be number, got string — <what the field is for> |
| A required setting is missing | `repos` is required — <what the field is for> |
| A setting is not one it declared | `retentionDay` is not a setting this plugin has |
| A plugin with no schema was given settings | has no settings, but the config gives it some |
| Two plugins claim the same port | the `tracker` port is already mounted by another plugin |
| Two plugins claim the queue, store, engine or workflow | the queue is already mounted by another plugin |
| Two contributions collide in one collection | `claude` is already in the `agent` collection |
| The workflow emits an action nothing defines | action `escalate`: nothing defines it |
| An action's port is not mounted | action `triage`: needs the `agent` port, which nothing mounted |
| An observation nothing contributes | observation `tracker`: nothing contributes it |
Every problem is reported, not just the first. One boot fixes one round of edits.
interface PluginContext {
readonly config: Record<string, unknown>; // its own slice, already validated
readonly runner: CommandRunner; // the only way to a child process
readonly now: () => Date; // never `new Date()`, so tests can drive time
readonly log?: EventLog;
readonly paths: HostPaths; // { workspace, state }
contributions(collection: string): ReadonlyMap<string, object>;
port(kind: PortKind): object | undefined;
workflow(): Workflow<never, never> | undefined;
}The last three are live views, and that matters more than it looks. A plugin that composes others cannot see contributions made after it, so it reads them when it is used rather than when it is mounted. Otherwise the order plugins appear in the config would be something an operator has to get right.
interface Registry {
queue(impl): void;
store(impl): void;
engine(impl): void;
workflow(impl): void;
port(kind, impl): void;
action(name, spec, port): void;
observer(slice, source): void;
contribute(collection, name, impl): void;
}A port is a named slot. One plugin fills it; a second one trying is a refusal. See Ports.
An action the core does not have may be registered, and when it is, the plugin has to bring the port that runs it in the same package. The pair is inseparable on purpose: an action nobody can execute is a promise the machine cannot keep. If such an action proves general it graduates into the core, by evidence rather than guess.
An observation is one named slice of what a workflow reads before deciding.
A contribution adds to a named collection that some other plugin consumes.
The core does not know what a collection means, only that several plugins may add to one and something else will read it. It is how the notification channels reach the fan-out without the core learning the word "channel".
| Collection | Contributed to by | Read by |
|---|---|---|
agent |
claude — @amykit/plugin-claudecodex — @amykit/plugin-codexhermes — @amykit/plugin-hermes-agent |
@amykit/agent-kit |
harness |
claude — @amykit/plugin-claudecodex — @amykit/plugin-codexhermes — @amykit/plugin-hermes-agent |
@amykit/agent-kit |
notify-channel |
hermes — @amykit/plugin-notify-hermesinbox — @amykit/plugin-notify-inboxtracker — @amykit/plugin-linear |
whichever plugin reads it |
workflow-runtime |
errand — @amykit/workflow-errandnote-to-plan — @amykit/workflow-note-to-planticket-to-qa — @amykit/workflow-ticket-to-qa |
@amykit/agent-kit |
A collection is read when it is used, not when it is mounted, so the order plugins are listed in does not matter.
ready?(ctx: PluginContext): void | Promise<void>;Checked once every plugin has registered, and only for a plugin whose settings cannot be judged before then.
The agent relay is the case it exists for. Its ladder: [claude:sonnet, codex:gpt-5] names harnesses that other plugins contribute, and it cannot
tell whether that list makes sense until they have. Throwing in ready is a
refusal at boot, named — the same promise register makes.
A ladder that quietly meant less than it says would first show up as a ticket escalating for no reason.
A plugin declares what its settings look like, and the host validates them without knowing what any of them mean:
export const configSchema: ConfigSchema = {
directory: {
type: "string",
description: "where the queue is kept, relative to the state directory",
default: "queue",
},
retentionDays: { type: "number", description: "…", default: 7 },
};Five types only — string, number, boolean, string[], record — and that
ceiling is deliberate. A plugin that needs more shape than this wants its own
validation, not a bigger schema language in the host.
The description is not decoration: it is printed when the field is missing or
wrong. "invalid config" with nothing else in it is the error message that
costs an hour.
The exported plugin is a module singleton. A field on it would be shared
by every host in the same process, and the second mount would answer with the
first one's state.
The shipped plugins that build something expensive key it by context:
const relays = new WeakMap<PluginContext, AgentRelay>();The context is per mount, which makes it the right key. If your plugin holds
anything built from ctx, do the same.
| Plugin | What it is | Mounts | Contributes |
|---|---|---|---|
@amykit/plugin-agent-relay |
One agent made of several: swaps harness on a quota, escalates model on a failure. | agent |
|
@amykit/plugin-claude |
The claude CLI as the agent, with git on the side. | agent:claudeharness:claude |
|
@amykit/plugin-codex |
The codex CLI as the agent, over its JSONL event stream. | agent:codexharness:codex |
|
@amykit/plugin-command |
Any command line tool, reached by a name the config allows. | commands |
|
@amykit/plugin-command-gate |
A gate that runs the target repository's own commands. | gate |
|
@amykit/plugin-file-notes |
Friction as a directory of notes: written by hand, by a hook, or by a tick that failed. | notes |
|
@amykit/plugin-file-queue |
A queue kept as one file per item, claimed by rename. | queue |
|
@amykit/plugin-file-store |
Work records kept as one file per item. | store |
|
@amykit/plugin-file-tasks |
Tasks as a directory of files: written by amy btw, by an editor, or by a hook. |
tasks |
|
@amykit/plugin-github |
GitHub as the code host, through the gh CLI. | code-host |
|
@amykit/plugin-hermes-agent |
Hermes as the agent, over its one-shot mode and usage report. | agent:hermesharness:hermes |
|
@amykit/plugin-linear |
Linear as the tracker, over its GraphQL API. | tracker |
notify-channel:tracker |
@amykit/plugin-notify-fanout |
Sends one announcement down every configured channel, and keeps going when one is down. | notifier |
|
@amykit/plugin-notify-hermes |
Announcements over Hermes, which already owns the messaging credentials. | notify-channel:hermes |
|
@amykit/plugin-notify-inbox |
Announcements as a file on disk plus a desktop notification. | notify-channel:inbox |
|
@amykit/plugin-plan-check |
The quality bar for a drafted plan: the repository's own check, run in its checkout. | plan-check |
|
@amykit/plugin-serial-engine |
Advances one work item by one move per tick. | the engine |
Write a plugin goes from an empty package to a proven one, including the gate that expires when you change it.