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. handlersis 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 -.-> mergeProvider
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).