Architecture

Every diagram on this page, and the rest of the system, is collected on the diagrams index.

Amber is compiled C++17 from the runtime up. The agent loop, context management, tools, plugin system and clients share the same native core. The result is a small, explicit runtime with no Python or Node dependency, while the core remains usable as a library rather than being tied to a single interface.

flowchart TB
    hosts["Hosts<br/>amber TUI · amber-cli · amber-bench"]

    subgraph kernel["libagent_core: microkernel"]
        runtime["PluginRuntime<br/>capability ledger"]
        registries["Contribution registries<br/>tools · providers · commands · status · panels"]
        agent["Agent loop + Context"]
        events["Typed EventBus"]
        transport["LLM transport<br/>openai protocol"]

        runtime -->|install / unwind| registries
        registries --> agent
        agent --> events
        agent --> transport
    end

    bundled["Bundled plugins: compiled in<br/>providers · core tools · status segments · panels"]
    external["External plugins<br/>separate process · JSON-RPC over stdio<br/>tools only"]
    endpoint["OpenAI-compatible endpoint"]

    bundled -->|declare Capability| runtime
    external -->|tool.call| registries
    transport --> endpoint
    hosts -->|AgentHooks| agent
The microkernel: hosts above, plugin contributions installed into the core registries through the ledger.

Runtime

The runtime is the engine: the agent loop, context management, and the plugin system that installs capabilities. It has no UI and nomain(). The three clients (amber,amber-cli, amber-bench) are thin shells over it. Embed the library in your own C++ project and you get the agent loop, tool registry, compression pipeline, plugin runtime and MCP transport without a terminal in sight.


Agent Loop

The agent loop sends the context to the LLM, receives tool calls, executes them through the tool registry, feeds results back, and repeats until the agent terminates. Streaming is via SSE; token deltas are surfaced through AgentHooks for live TUI rendering.

flowchart TD
    run["Agent::run(prompt)"] --> seed["ensure_system_prompt<br/>history always starts with a system message"]
    seed --> loop{"iteration under max_tool_iterations<br/>and not done?"}
    loop -->|no| stop["hard stop<br/>loop detected, or tools kept failing"]
    loop -->|yes| gate{"compression gate<br/>context utilisation over threshold?"}
    gate -->|compress| rebuild["compression pipeline<br/>context rebuilt: clear + push"]
    gate -->|keep| assemble
    rebuild --> assemble["assemble the prompt copy<br/>head + tail blocks, after the gate"]
    assemble --> llm["LLM call, streamed via SSE"]
    llm --> parse["parse tool calls"]
    parse -->|none| confirm["silent confirm turn<br/>are you finished?"]
    confirm -->|done| reply["return the final reply"]
    confirm -->|continue| loop
    parse -->|calls| dispatch["dispatch tool calls<br/>parallel execution, ordered results"]
    dispatch --> push["push tool-role messages"]
    push --> loop
One turn of the ReAct loop: gate, assemble, call, dispatch, confirm.
flowchart TD
    each["evaluated every iteration"] --> tool{"3 or more identical<br/>tool call sets in a row?"}
    tool -->|yes| stopTool["hard stop: loop detected"]
    tool -->|no| text{"identical reply text<br/>repeating?"}
    text -->|second time| steerText["inject a steering message"]
    text -->|fifth time| stopText["hard stop"]
    text -->|no| fail{"consecutive tool failures"}
    fail -->|3| steerFail["inject a recovery steer"]
    fail -->|6| stopFail["hard stop: tool calls kept failing"]
    steerText --> hist["steer pushed to history_<br/>the model sees it on the next turn"]
    steerFail --> hist
    hist --> each
Three failure modes, two responses each: steer first, hard stop if it persists.

Context

The Context class is a pure stack, messages are sealed on push() and can never be modified in-place. Every push computes an FNV-1a hash chain (h_i = FNV(prev_hash || msg)); get_all()recomputes the entire chain and asserts integrity before returning. Any in-place mutation breaks a link and crashes in debug builds.

h_0 = FNV(0              || msg_0)
h_1 = FNV(h_0            || msg_1)
h_2 = FNV(h_1            || msg_2)
...
chain_hash_ = h_n-1

The only mutation operations are push (append),pop (remove top, LIFO), clear (remove all), and get_all (read-only). There is no replace, insert, or update, if you need to rebuild, clear() thenpush() each message.

flowchart TB
    subgraph ops["the only mutation operations"]
        push["push(msg): seal and append<br/>h = FNV(prev_hash, msg)"]
        pop["pop(): remove the top, LIFO<br/>restores the previous hash in O(1)"]
        clear["clear(): remove all<br/>used only by the compression rebuild"]
        get["get_all(): read-only view<br/>recomputes and asserts the chain"]
    end

    push --> chain["parallel deque of hashes"]
    pop --> chain
    clear --> chain
    chain --> verify["any in-place mutation breaks a link<br/>and asserts in a debug build"]
    get --> verify
    verify --> owner["single ownership, not locking<br/>one writer thread: everyone else reads snapshots"]
A pure stack: four operations, a hash chain, and single ownership instead of locking.

Capability / Plugin System

The plugin system is the extensibility backbone. Plugins contribute tools, LLM providers, commands, prompt blocks, status segments, panels, settings, and log sinks, and observe the agent loop through typed events.

Three mechanisms:

  • Contribution registries: things that exist (tools, providers, status segments). A core plugin declares typed Capability objects; the runtime installs each into its registry and records it in a per-plugin ledger.
  • Typed events: things that happen. Payload structs in events.h; no void* in the plugin-facing API. Unsubscribed publish() is a single atomic load, no per-token events.
  • Host services: things the plugin needs the host to do. Registration never happens through events; UI state is pulled at render time, not pushed from worker threads.

disable() unwinds the ledger in reverse order, if a contribution can survive deactivation, the ledger is broken and that is a tested invariant. Every provider amber ships is a plugin, and the core declares none. With no plugins registered,/provider list is empty.

stateDiagram-v2
    [*] --> Registered
    Registered --> Active: enable - initialize + install
    Active --> Deactivated: disable - shutdown + unwind ledger
    Deactivated --> Active: enable
    Registered --> Failed: initialize returned false
    Active --> Failed: install failed
    Failed --> Registered: fix and reload
    Deactivated --> [*]
Registered, Active, Deactivated, Failed, and what activation and deactivation actually do.

Providers

Provider integrations live behind the same plugin architecture. A provider capability is flavor plus optional dialect factory plus presets; wire behavior lives in the Dialect. A provider plugin must not open its own HTTP client, it uses the transport'sopenai protocol, so a disabled provider plugin takes its protocol with it.

Bundled provider plugins: Anthropic, Gemini, OpenRouter, DeepSeek, KiloCode, OpenCode, CommandCode, Custom, and OpenAI-compatible endpoints. Each owns its presets, protocol, and provider-specific features.

flowchart TB
    provider["provider plugin<br/>flavor + presets + optional dialect factory"] --> select["Config::flavor set at selection"]
    select --> make["make_dialect(flavor)<br/>resolved exactly once per client"]
    make -->|known flavor| dialect
    make -->|unknown flavor| fallback["openai fallback<br/>a typo never breaks a session"]
    fallback --> dialect["Dialect implementation<br/>endpoints · auth · body shape"]
    dialect --> request["build request body and headers"]
    request --> transport["HTTP transport via libcurl<br/>retry · timeout · cancel check"]
    transport --> endpoint["OpenAI-compatible endpoint"]
    endpoint --> decode["stream decoder: SSE framing"]
    decode --> chunks["StreamChunk per event → AgentHooks"]
    decode --> usage["TokenUsage mapping"]
    decode --> errors["is_retryable classification<br/>typed ApiError"]
    request --> normalize["tool calls normalized to the internal shape<br/>at the dialect edge, buffered and streamed alike"]
Provider plugin to endpoint, with the Dialect port resolving exactly once in the middle.

Tools

The built-in tool set (read, write, search, bash) arrives through the same capability path an extension would use, so the path cannot rot. Each tool has a name, description, parameter schema, and anexecute method. The bash tool is approval-gated, each call must be approved by the host before it runs; if no approval handler is installed the call is denied (fail-safe).

The search tool is pluggable: mode="grep" (default, wrapsgrep -rnI) or mode="semantic"(dependency-free lexical index). Swap the embed() step to use a real embedding model.

flowchart TD
    calls["tool calls from the LLM reply"] --> validate{"known tool,<br/>valid JSON arguments?"}
    validate -->|no| denied["denied<br/>tool not found / invalid JSON"]
    validate -->|yes| dup{"duplicate of an identical call<br/>in the last assistant turn?"}
    dup -->|yes| denied
    dup -->|no| policy{"PolicyStore level<br/>read / write / yolo"}
    policy -->|read mode blocks it| denied
    policy -->|needs approval| gate{"approval gate"}
    gate -->|denied| denied
    gate -->|allowed| exec
    policy -->|allowed| exec["execute in parallel via std::async"]
    exec --> collect["collect results"]
    collect --> ordered["append in original order<br/>exactly one tool-role message per call"]
    denied --> ordered
    ordered --> envelope["feedback envelope<br/>status ok / error / denied"]
Every tool call, and every way it can be denied, ending in one ordered envelope per call.
flowchart TD
    query["search tool call<br/>mode argument"] --> pick{"mode"}
    pick -->|semantic| sem["SemanticBackend<br/>dependency-free lexical index<br/>cached per root and glob"]
    pick -->|anything else| grep["GrepBackend<br/>wraps grep -rnI, stateless"]
    sem --> fresh["a backend is created fresh per execute()"]
    grep --> fresh
    fresh --> format["results formatted into the tool envelope"]
One port, two backends, and a grep fallback for anything unrecognised.

MCP

Amber connects to MCP (Model Context Protocol) servers over the supported transports, extending the agent with external tools and services. The ServerManager handles lifecycle.

sequenceDiagram
    autonumber
    participant U as User
    participant H as amber, the MCP host
    participant T as Transport
    participant S as MCP server

    U->>H: /mcp connect
    H->>T: open transport, stdio or streamable HTTP
    T->>S: spawn the process, or POST
    H->>S: initialize - protocol negotiation
    S-->>H: server capabilities
    H->>S: tools/list
    S-->>H: tools, paginated
    H->>S: resources/list, prompts/list
    S-->>H: resources, prompts
    Note over H,S: adapters map all three onto the Tool port
    S-->>H: notifications/listChanged
    U->>H: invoke a server tool
    H->>S: tools/call
    S-->>H: result
    U->>H: disconnect
    H->>S: shutdown
Connect, negotiate, discover, adapt, invoke, shut down.

Sub-agents

SubAgentExecutor runs focused sub-agents with their own context and a custom system prompt. Execution mode is runtime-configurable:

  • Serial: sub-agents run one at a time; sequential requests share the system-prompt prefix, keeping provider prompt caches warm.
  • Parallel: sub-agents run concurrently, capped by max().
flowchart TB
    task["task tool call"] --> mode{"execution mode"}
    mode -->|serial| serial["one at a time<br/>sequential requests share the system-prompt prefix<br/>so provider prompt caches stay warm"]
    mode -->|parallel| parallel["concurrent, capped by max()"]
    serial --> build
    parallel --> build["each sub-agent gets its own Context<br/>and a custom system prompt"]
    build --> iters["max_iterations cap, default 20"]
    iters --> result["result returned to the parent agent"]
Serial for cache warmth, parallel for throughput: each with its own Context.

Compression Pipeline

Two-phase pipeline: classify-then-extract. Phase 1 classifies the conversation into core/context/prune segments. Phase 2 extracts memory and skill operations. The pipeline is pure, the context is only read. Classify/extract requests are assembled from a working copy of the snapshot, so a failed pipeline leaves the context untouched by construction. The rebuild (clear() +push()) happens only on success.

  • Classify: segments the conversation by importance
  • Extract: pulls memory/skill ops from the LLM
  • Prune: replaces bulky tool outputs with placeholders
  • Headroom: enforces 25% headroom after compression
sequenceDiagram
    autonumber
    participant A as Agent
    participant C as Context (live)
    participant L as LLM

    A->>C: read the snapshot
    C-->>A: messages (Context stays untouched)
    A->>A: collapse consecutive tool-call loops

    Note over A,L: both requests share a content-identical prefix,<br/>so the second extends the first's KV cache

    A->>L: classify request
    L-->>A: classification json - core / context / prune
    A->>L: extract request, replaying the classify prefix
    L-->>A: extraction json - memories + skills

    A->>A: apply classification, enforce headroom
    A->>C: clear() then push() the compressed set
    Note over A,C: the rebuild is the only live-context change,<br/>and only on success; a failure leaves it untouched
Classify then extract, sharing one prefix, and why the cycle costs no full prefill.

Security Model

Amber treats model output as untrusted input.

  • Workspace confinement: read/write confine paths to the workspace root (default cwd, override with AMBER_WORKSPACE); absolute paths and ../ escapes are rejected
  • Approval gating: bash tool requires explicit user approval; denied (fail-safe) if no approver
  • Process isolation: commands run in their own process group
  • Output capping: command output capped at 64 KiB
  • Timeout: default 60s timeout on shell commands
flowchart TD
    call["tool call"] --> mode{"agent mode"}
    mode -->|read| block["read mode blocks<br/>every non-read-only tool"]
    mode -->|write or yolo| classify{"shell_classify<br/>bash only"}
    classify -->|read-only, or benign in-workspace write| run["runs free"]
    classify -->|destructive, or outside the workspace| policy
    classify -->|not a bash call| policy{"stored or session rule?"}
    policy -->|granted| run
    policy -->|no rule| handler{"approval handler installed?"}
    handler -->|no handler| deny["denied: fail-safe"]
    handler -->|non-TTY with no --yes| deny
    handler -->|TTY| dialog["approval dialog<br/>prompted once per scope: kind or folder"]
    dialog -->|approve| grant["session grant recorded"]
    grant --> run["tool executes<br/>own process group, stdin to /dev/null"]
    dialog -->|deny| deny
    block --> deny
Mode, policy and approval, ending in a fail-safe deny when no approver exists.
flowchart TD
    input["path from the tool call"] --> empty{"empty?"}
    empty -->|yes| reject
    empty -->|no| root["resolve the root<br/>cached, then $AMBER_WORKSPACE, then cwd"]
    root --> normalize["lexical normalisation<br/>no filesystem access during resolution"]
    normalize --> escape{"escapes the root?<br/>dot-dot traversal or an absolute path outside"}
    escape -->|yes| reject["rejected"]
    escape -->|no| prefix{"slash-terminated prefix match<br/>/ws2 is not inside /ws"}
    prefix -->|no| reject
    prefix -->|yes| ok["confined absolute path returned"]
    ok --> io["only now does any file I/O happen"]
Purely lexical, checked before any file I/O, with slash-terminated prefix matching.

Host Clients

The three clients link libagent_core.a +libagent_tools.a and communicate viaAgentHooks:

  • amber: interactive ncurses TUI (BitchX-inspired multi-window, sessions, slash commands, jobs, plugin controls, approval dialogs)
  • amber-cli: headless CLI for scripts, CI, automation, scheduled jobs
  • amber-bench: benchmark and KPI harness (scenario loader, oracle scorer, recorder as an AgentHooks observer)

Build System

Standard configure + make. The GNUmakefile auto-runs ./configure to generate Makefile from Makefile.in:

make          # build everything (lib + cli + tui)
make test     # run unit tests
make check    # smoketest + build hygiene
make lint     # run clang-tidy
make analyze  # run cppcheck
make clean    # remove .o / .d / binaries
make distclean # also removes the generated Makefile
flowchart TB
    subgraph core["libagent_core.a: UI-free core"]
        engine["agent loop · context · dispatch<br/>plugin runtime · event bus · ledger"]
        dialects["LLM dialects + HTTP transport"]
        bundled["bundled plugins, compiled in<br/>providers · core tools · hello"]
    end

    subgraph toolslib["libagent_tools.a"]
        tools["read · write · search · bash<br/>process · task · todowrite · skills"]
    end

    toolslib -->|depends on core| core

    core --> cli["amber-cli<br/>headless"]
    core --> tui["amber<br/>ncurses TUI"]
    core --> bench["amber-bench<br/>KPI harness"]
    toolslib --> cli
    toolslib --> tui
    toolslib --> bench

    tui --> nclib["ncurses + md4c"]
    cli --> curl["libcurl"]
    tui --> curl
    bench --> curl

    ext["external plugins<br/>separate processes, JSON-RPC<br/>never linked into any client"] -.->|process boundary| core
    note["core and tools appear twice on every link line<br/>to resolve the circular reference between them"]
Two static libraries, three clients, and the process boundary external plugins live behind.
amber 0.5.8Apache 2.0C++17
utf-8ncursesJacek Trefon www.trefon.com2026