Capabilities

One capability = one contribution. kind() tells the runtime which registry installs it; install() returns a handle the ledger keeps. There are eight kinds, each with a concrete base class ininclude/agent/extensions.h.

Tool

The same path amber's own tools take, the built-in read/write/search/bash set arrives through ToolCapability, so the path cannot rot.

class Tool {
public:
    virtual std::string name() const noexcept = 0;
    virtual std::string description() const noexcept = 0;
    virtual json parameters_schema() const = 0;   // OpenAI "parameters" object

    virtual bool requires_approval(const json& args) const noexcept { return false; }
    virtual bool is_read_only() const noexcept { return false; }
    virtual std::string summarize(const json& args) const { return name(); }

    virtual ToolResult execute(const json& arguments) const = 0;
};
class GreetTool : public agent::Tool {
public:
    std::string name() const noexcept override { return "greet"; }
    std::string description() const noexcept override { return "Greet someone by name."; }

    agent::json parameters_schema() const override {
        return agent::json{
            {"type", "object"},
            {"properties", {{"name", {{"type", "string"}}}}},
            {"required", agent::json::array({"name"})}};
    }

    agent::ToolResult execute(const agent::json& args) const override {
        return {true, "Hello, " + args.value("name", "world") + "!", "", agent::json{}};
    }
};

// In capabilities():
caps.push_back(std::make_unique<agent::ToolCapability>(
    "greet", [](agent::PluginServices&) {
        std::vector<std::unique_ptr<agent::Tool>> tools;
        tools.push_back(std::make_unique<GreetTool>());
        return tools;
    }));

Give it a factory when the tool needs harness services at construction. Returning an empty list declines, the tool is simply absent and the plugin stays active:

// A tool that needs a host service is built by a factory.
caps.push_back(std::make_unique<agent::ToolCapability>(
    "task", [](agent::PluginServices& svc) {
        std::vector<std::unique_ptr<agent::Tool>> tools;
        if (!svc.host || !svc.host->subagents)
            return tools;   // decline: nothing to bind to
        tools.push_back(std::make_unique<TaskTool>(*svc.host->subagents));
        return tools;
    }));
  • Return errors as ToolResult{false, "", error}: never throw.
  • Returning several tools from one factory makes them one contribution: one ledger entry, removed together.
  • Tool ownership is recorded by the registry, so a plugin can never remove another plugin's tool.

Prompt block

A block of text appended to the prompt as its own system message, on theprompt copy: the sealed Context is never mutated. Ordered by (priority, registration); core blocks use 100/200/300.

class ProjectFactsBlock : public agent::PromptBlockCapability {
public:
    ProjectFactsBlock() : PromptBlockCapability("project_facts", /*priority=*/400,
        [this] { return has_changed() ? "Project facts: ..." : ""; }) {}
};

Determinism matters. A block whose content changes every turn invalidates the server's KV prefix from its position onward. Keep volatile content at a high priority (near the tail), or cache until the fact it reports actually changed.

Status segment

One segment of the status bar. Segments are composed byStatusRegistry in (priority, registration) order;drop_priority decides what disappears first when the bar is too narrow.

class BalanceSegment : public agent::StatusSegmentCapability {
public:
    BalanceSegment()
        : StatusSegmentCapability("kilocode_balance", /*priority=*/500,
                                  /*drop_priority=*/10,
              [this](const agent::StatusSnapshot&) {
                  return agent::StatusText{
                      balance_ < 0 ? "" : "$" + fmt(balance_),
                      agent::StatusTone::Good};
              }) {}
};

The render callable runs inside frame composition on the UI thread: pure, fast, no I/O, no locks. Update the value from an event handler, and let tick() do time-driven work.

struct StatusSnapshot {
    int window_index, window_count;
    std::string model, reasoning_effort;
    AgentMode mode;
    bool scroll_mode;
    long latency_ms, prompt_tokens, completion_tokens;
    double tps;
    int running_jobs, job_seconds_left;
    std::string running_tool;
    std::vector<StatusMcpServer> mcp_servers;
};

enum class StatusTone { Dim, Good, Warn, Crit, Accent, Banner };

Panel

A full-screen view. The host owns placement, framing, scrolling and key routing; a panel only produces text and optionally consumes keys while focused.

agent::PanelSpec spec;
spec.id = "gemini_models";
spec.title = "Gemini models";
spec.lines = [this](int width) { return model_lines(width); };  // pure, per repaint
spec.on_key = [](int key) { return key == 'r'; };               // optional
caps.push_back(std::make_unique<agent::PanelCapability>(std::move(spec)));

lines(width) is called on every repaint, keep it pure and fast. There is deliberately no global key interception.

Command

A slash-command namespace: a root, its help/man, the child nodes (completions.json shape), and one handler per executable leaf. The host merges the subtree into the command tree and binds each leaf to its handler.

agent::CommandSpec spec;
spec.root = "hello";
spec.help = "Say hello";
spec.man  = "Usage: /hello greet <name>";
spec.subtree = agent::json::parse(R"({
    "greet": {"help": "Greet someone by name"}
})");
spec.handlers["greet"] = [](const std::string& arg) {
    return "Hello, " + (arg.empty() ? std::string("world") : arg) + "!";
};
caps.push_back(std::make_unique<agent::CommandCapability>(std::move(spec)));
  • The handler returns the text to display; the host prints it. That keeps the plugin UI-free, no tui/ includes.
  • handlers is keyed by the leaf's dotted path relative to the root ("greet", "config.set"). A leaf with no handler stays inert.
  • You never name an action string, the host derives it (plugin.<id>.<path>), so a plugin command cannot collide with a core one.
  • Toggling the plugin rebuilds the tree immediately: commands appear or disappear with no restart.
  • The TUI owns the slash engine; the headless CLI has no command tree, so this is TUI-only today.

The bundled plugins/hello/ is the worked example,/hello greet amber returns Hello, amber!.

flowchart TB
    declare["Plugin declares CommandSpec<br/>root · help · man · subtree · handlers"]
    install["Ledger installs into CommandRegistry"]
    merge["Host merges the subtree into the command tree<br/>executable leaf action = plugin.{id}.{path}"]
    drawer["Drawer + completions"]
    dispatch["Dispatch resolves the handler<br/>from the live registry"]
    inert["Leaf with no handler<br/>documented, not executable"]
    toggle["enable / disable rebuilds the tree, no restart"]

    declare --> install
    install --> merge
    merge --> drawer
    merge --> dispatch
    merge --> inert
    toggle -.-> merge
The host derives the action and resolves the handler from the live registry at dispatch time.

Provider

Contributes an LLM provider: its presets, and optionally the wire protocol they speak. A provider that speaks a shared protocol (an OpenAI-compatible gateway) passes no dialect factory: it contributes presets and leaves the shared protocol alone, so switching it off cannot take the shared protocol down with it.

agent::ProviderCapability::Preset preset;
preset.name = "gemini";
preset.api_base = "https://generativelanguage.googleapis.com";
preset.default_model = "gemini-2.5-pro";
preset.requires_key = true;

caps.push_back(std::make_unique<agent::ProviderCapability>(
    "gemini",                              // flavor (wire protocol)
    [] { return make_gemini_dialect(); },  // dialect factory; {} for shared openai
    std::vector<agent::ProviderCapability::Preset>{preset}));

Everything wire-specific belongs in the Dialect, not in the plugin's plumbing, and a provider plugin must not open its own HTTP client. Full walkthrough: Providers.

Wallet

A provider's account balance. Supply only the fetch; the runtime owns when to poll, how to cache, and how to render, so every provider's readout behaves identically and no plugin carries a poll loop.

caps.push_back(std::make_unique<agent::WalletCapability>(
    [](const agent::Config& cfg) -> std::optional<double> {
        if (cfg.api_key.empty()) return std::nullopt;      // nothing to fetch with
        const auto body = agent::http_get_with_bearer(url, cfg.api_key);
        if (!body) return std::nullopt;                    // failed: claim nothing
        const double amount = parse_balance(*body);
        return amount < 0 ? std::nullopt : std::optional<double>(amount);
    }));

Return the amount, or std::nullopt when there is nothing honest to report. nullopt is normal, the bar shows -. Never invent a value. The fetch runs off the UI thread at most once per turn boundary.

Allowance

Subscription or quota windows (rolling 5-hour sessions, weekly caps, monthly credits). Same model as the wallet: the plugin supplies the fetch, the runtime owns polling, caching and rendering.

caps.push_back(std::make_unique<agent::AllowanceCapability>(
    [](const agent::Config& cfg) -> std::optional<agent::AllowanceSnapshot> {
        if (cfg.api_key.empty()) return std::nullopt;
        const auto body = agent::http_get_with_bearer(url, cfg.api_key);
        if (!body) return std::nullopt;
        return parse_usage(*body);
    }));
struct AllowanceWindow {
    std::string label;        // "5h", "7d", "monthly"
    double percent_used = -1; // 0-100, -1 = unknown
    std::string resets_at;    // ISO 8601 or empty
    double remaining = -1;    // count or credits, -1 = n/a
    double entitlement = -1;
};

struct AllowanceSnapshot {
    std::string plan;
    std::vector<AllowanceWindow> windows;
    std::optional<double> credits_balance;
    std::string unit, currency;
};

The bar shows the closest affecting window: the highestpercent_used, ties broken by the shortest label. Returnnullopt on any failure; the bar shows -, never a fake zero.

Not extension points

Per-plugin settings and log sinks were removed or deferred, they had no producer and no consumer. There is no SettingCapability orLogSink in the tree, and host services to the user are not implemented yet. Events are subscribed directly (seeEvents).

amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026