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

  • initialize is where you capture the context. Returning false marks the plugin Failed and installs nothing.
  • shutdown releases plugin-owned resources. Do not unregister contributions, the ledger does that.
  • tick runs 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 ships todowrite and task that 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 reachable
Declare, install, record; then shutdown and unwind in reverse install order.

Lifecycle

StateMeaning
RegisteredKnown to the runtime; nothing installed.
ActiveEnabled; capabilities installed and ledgered.
DeactivatedDisabled; ledger unwound, nothing reachable.
Failedinitialize 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

RuleWhy
Declare capabilities; let the runtime install themOnly the ledger can guarantee a clean disable
No screen access, no ncursesPlugins must run headless
Render callables are pure and fastThey run inside frame composition
Never mutate ContextIt is a sealed, hash-chained stack
No per-token workThe streaming path is the hot path
No blocking in event handlersThey run on the agent's owner thread
Errors are returned, not thrownToolResult / InstallResult conventions
amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026