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| agentRuntime
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 --> loopflowchart 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 --> eachContext
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"]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
Capabilityobjects; 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; novoid*in the plugin-facing API. Unsubscribedpublish()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 --> [*]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"]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"]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"]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: shutdownSub-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"]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 untouchedSecurity 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 --> denyflowchart 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"]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
AgentHooksobserver)
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"]