Plugin anatomy
A core plugin is a C++ class implementing IPlugin plus the capabilities it declares. Everything the plugin contributes is declared once, in capabilities(), and installed by the runtime.
IPlugin
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual std::string id() const = 0; // slug, unique; the ledger owner
virtual std::string version() const = 0; // semver
virtual std::string name() const = 0; // display
// Optional. Shown in the registry list; category groups it there.
virtual std::string description() const { return {}; }
virtual std::string category() const { return plugin_category::kOther; }
virtual bool initialize(const PluginContext& ctx) = 0; // false => Failed
virtual void shutdown() = 0; // release own state
// Time-driven work, called on the UI thread. Must not block.
virtual void tick() {}
// Called once at activation. The runtime installs each and ledgers it.
virtual std::vector<std::unique_ptr<Capability>> capabilities() { return {}; }
};Only id, version, name,initialize, shutdown and capabilitiesare required. A plugin with no capabilities still participates in the lifecycle (the metrics plugin is exactly that, it only observes events).
id
A slug ([a-z0-9_]+) that is unique across both tiers. It is the ledger owner: everything the plugin contributes is recorded against it, and disabling it unwinds exactly those contributions. It is also the directory name (plugins/<id>/) and the state-file key.
description and category
Both optional, both shown by the registry console. The category is open,agent::plugin_category holds the common values (kProvider, kObservability, kTools,kUi, kMemory, kSearch,kOther), and an unknown value groups under its own heading rather than being hidden.
initialize / shutdown / tick
initializeis where you capture the context. Returningfalsemarks the plugin Failed and installs nothing.shutdownreleases plugin-owned resources. Do not unregister contributions, the ledger does that.tickruns on the UI thread for time-driven work (polling a balance, refreshing a remote value). It must not block; slow work goes on your own thread. It exists so render callables can stay pure reads.
PluginContext
What initialize receives:
struct PluginContext {
EventBus& event_bus; // typed events live on top of this
const ToolRegistry& tools; // read-only view
const Config* config; // the host's LIVE config; read at use time
const Workspace& workspace;
};Read config at the moment you need it; never cache the pointer. The host may attach the configuration it actually mutatesafter your plugin was activated, so a cached pointer can keep pointing at the runtime's startup copy and your feature silently stops working. This regression is pinned byruntime_plugin_sees_the_hosts_config_attached_after_start.
PluginServices
What Capability::install receives:
class PluginServices {
public:
ToolRegistry& tools();
PromptRegistry& prompts();
StatusRegistry& status();
PanelRegistry& panels();
WalletRegistry& wallets();
AllowanceRegistry& allowances();
EventBus& events();
const std::string& owner() const; // the plugin being installed
const Config* config = nullptr; // host's live config
const HostServices* host = nullptr; // jobs, todos, sub-agents, cancel token
};The registries are the harness's own, a capability never constructs one. A capability that needs none of the host services ignoreshost entirely.
The Capability protocol
enum class CapabilityKind : std::uint8_t {
Tool, PromptBlock, StatusSegment, Panel, Provider, Wallet, Allowance,
};
class Capability {
public:
virtual ~Capability() = default;
virtual std::string name() const = 0; // unique within the plugin
virtual CapabilityKind kind() const = 0;
virtual InstallResult install(PluginServices&) = 0;
};CapabilityKind is the dispatch key: it tells the runtime which registry installs the contribution. There are eight kinds, each with a concrete base class, see Capabilities.
InstallResult: decline vs failure
struct InstallResult {
bool ok = false;
bool declined = false; // "nothing to install", NOT a failure
std::string error; // non-empty when ok == false && !declined
Contribution contribution;
};
struct Contribution {
CapabilityKind kind = CapabilityKind::Tool;
std::string name;
std::function<void()> remove; // the ledger's unwinder; may be null
};ok == true: installed; the contribution is ledgered.ok == false && declined == true: nothing to install, and that is normal. The plugin stays active. This is how a tool gated on configuration behaves (the core set shipstodowriteandtaskthat way).ok == false && !declined: broken. The plugin is marked Failed.
The ledger
Every accepted install returns a Contribution handle, and the runtime records it against the plugin id:
class PluginLedger {
public:
void record(const std::string& plugin_id, Contribution c);
std::size_t size(const std::string& plugin_id) const noexcept;
std::vector<Contribution> contributions(const std::string& plugin_id) const;
std::vector<std::string> owners() const;
void unwind(const std::string& plugin_id) noexcept; // newest first
void clear() noexcept;
};Disabling a plugin calls shutdown() and then unwinds its ledger in reverse install order. If any contribution can survive deactivation, the ledger is broken, and that is a tested invariant (plugin_disable_removes_every_contribution), not an aspiration.
sequenceDiagram
autonumber
participant H as Host
participant RT as PluginRuntime
participant P as Plugin
participant R as Registry
participant L as Ledger
H->>RT: enable(id)
RT->>P: initialize(ctx)
P-->>RT: true
RT->>P: capabilities()
P-->>RT: Capability[]
loop each capability, declaration order
RT->>R: install(services)
R-->>RT: Contribution handle
RT->>L: record(owner, Contribution)
end
Note over H,L: Active - every contribution reachable
H->>RT: disable(id)
RT->>P: shutdown()
RT->>L: unwind(owner), reverse install order
L->>R: remove() per Contribution
Note over H,L: Deactivated - nothing reachableLifecycle
| State | Meaning |
|---|---|
| Registered | Known to the runtime; nothing installed. |
| Active | Enabled; capabilities installed and ledgered. |
| Deactivated | Disabled; ledger unwound, nothing reachable. |
| Failed | initialize returned false, or an install failed. |
Registration never activates. State is read from the persisted file, so a restart reproduces the same harness. start() activates every plugin whose state says it is on; set_state(id, on) is the single write path backing /set plugin on|off <id>.
Registration and state
The shipped set is one list, make_bundled_plugins() inlib/plugins_bundled.cpp: so it is enumerable at a glance. Adding a plugin means a directory under plugins/<id>/, an object in Makefile.in, and one line there.
Runtime state lives in~/.config/amber/plugins/<id>/plugin.conf. Your plugin never reads or writes that file; the runtime does, and a disabled plugin is never initialized.
Rules the framework enforces
| Rule | Why |
|---|---|
| Declare capabilities; let the runtime install them | Only the ledger can guarantee a clean disable |
| No screen access, no ncurses | Plugins must run headless |
| Render callables are pure and fast | They run inside frame composition |
Never mutate Context | It is a sealed, hash-chained stack |
| No per-token work | The streaming path is the hot path |
| No blocking in event handlers | They run on the agent's owner thread |
| Errors are returned, not thrown | ToolResult / InstallResult conventions |