A field guide to how the binary is put together. This is a map for someone about to change the code, not marketing. The single source of truth is always the code itself; where this doc and the code disagree, the code wins.
agentty is an Elm-style application. The entire runtime is one pure function applied in a loop:
(Model, Msg) -> (Model, Cmd<Msg>)
- Model is the whole application state — one aggregate struct.
- Msg is a closed sum type of every event that can happen.
- Cmd is a description of side effects to run (network, disk, timers); the
runtime executes them and feeds their results back as new
Msgs.
Rendering is a second pure function, view : Model -> Element, delegated to
maya — a sister TUI engine pulled in as a git submodule. The host never
constructs chrome glyphs or makes layout decisions; it builds widget Config
values from Model state and maya owns every pixel, border, and animation.
The four maya Program hooks are bound in
include/agentty/runtime/app/program.hpp:
| Hook | Meaning |
|---|---|
init |
Load settings + recent threads via Store seam. |
update |
The reducer — src/runtime/app/update.cpp. |
view |
Model -> Element. |
subscribe |
Timers and the live stream subscription. |
visual_hash |
Render-skip gate; identical hash → skip frame. |
needs_warmup |
One-shot fast scrollback rehydration on resume. |
main.cpp is wiring only: parse argv, resolve credentials, construct the
concrete AnthropicProvider + FsStore, install them behind the Deps seam,
then hand AgenttyApp to maya::run.
include/agentty/ and src/ mirror each other by domain. Headers carry the
types and inline logic; src/ carries the heavier implementations.
domain/— pure data, no I/O.session,conversation,catalog,todo,profile, and the strong-id newtypes inid.hpp(ToolCallId,ThreadId,OAuthCode,PkceVerifier). Swapping two ids of different newtype is a compile error, not a debugging session.runtime/— the application proper.model.hpp— the composedModelplus UI-only sub-states (composer, pickers, palette, modals) that belong to no domain.msg.hpp— theMsgsum, split into domain sub-variants (see §4).app/update/<domain>.cpp— per-domain reducers.view/— theModel -> Elementpipeline, one file per widget family.
provider/— theProviderconcept and its implementations:anthropic/transport.cpp(HTTP/2 + SSE, the OAuth/Pro/Max default) andopenai/transport.cpp(any OpenAI-compatible endpoint — openai, groq, openrouter, together, cerebras, ollama, or a raw host).selection.cppresolves which one a--providerflag / persisted setting picks.tool/— theToolconcept, the registry, the permission policy, and one file per tool undertool/tools/.memory_store.cppbacksremember/forget.scope/— the config-resolution algebra (Locus × Dialectprecedence, provenance-carryingSource,resolve_first/resolve_unionfolds, content-boundTrust). One pure primitive that memory, skills, agents, and slash-command discovery all fold through instead of hand-rolling roots. Seedesign/scope-model.md.io/—http,tls(certificate pinning),auth(OAuth + PKCE),persistence(atomic writes),clipboard.airgap/— SOCKS5-over-SSH so the agent can run on a host with no direct internet while the laptop relays the bytes.
3. Seams: how concrete types stay hidden
AgenttyApp must not be templated on the Provider and Store types — that would
force every translation unit to know the concrete types and rebuild when they
change. Instead, include/agentty/runtime/app/deps.hpp defines a small
Deps struct of std::functions:
- Provider seam —
stream(Request, EventSink). - Store seam —
save_thread,load_threads,load_thread,load_settings,save_settings,new_thread_id,title_from. - Auth context — the typed
AuthHeaderfor the session.
main.cpp calls app::install(provider, store, auth_header) once at startup;
the reducer reaches the seams through app::deps(). update_auth(...)
live-swaps credentials after an in-app login without restarting the process —
in-flight streams cached the header at request-build time, so they are
unaffected.
The Provider concept is deliberately tiny:
template <class P>
concept Provider = requires(P& p, Request req, EventSink sink) {
{ p.stream(std::move(req), std::move(sink)) } -> std::same_as<void>;
};Anything that streams a chat completion satisfies it — the real Anthropic and OpenAI-compatible transports in production, a deterministic in-memory script in tests.
The four transports (Anthropic, OpenAI-compat, Ollama-native, ChatGPT/Codex
Responses) each own their provider-specific streaming/salvage logic, but every
ingress concern that is genuinely SHARED lives in exactly one place so a fix
can't drift into three copies of itself: provider::finish_stream +
wire::SseFramer/LineFramer (byte-level framing + terminal-event epilogue),
provider::parse_retry_after (Retry-After backoff), wire::scrub_utf8
(strict UTF-8 validation), wire::could_be_tool_json (the leaked-tool-call
prefix sniffer weak local models need), provider::usage::{from_openai, from_responses,from_ollama} (the three token-usage wire shapes), and
auth::bearer_token (OpenAI-family auth header emission — Anthropic keeps its
own arm-typed visit since an API key must route to x-api-key, never
Bearer).
A naive design inlines every leaf event in one giant variant. That pins
sizeof(Msg) to the heaviest leaf, instantiates an N-wide std::visit
dispatch table, and forces the whole reducer TU to rebuild on any leaf change.
agentty instead groups leaves into ~15 domain sub-variants in msg.hpp
(ComposerMsg, StreamMsg, ToolMsg, ModelPickerMsg, ThreadListMsg,
CommandPaletteMsg, MentionPaletteMsg, SymbolPaletteMsg, TodoMsg,
LoginMsg, DiffReviewMsg, CheckpointMsg, MetaMsg). The top-level reducer in
update.cpp is then a small std::visit that forwards each domain to its own
TU:
auto step = std::visit(overload{
[&](msg::ComposerMsg cm) { return detail::composer_update(std::move(m), std::move(cm)); },
[&](msg::StreamMsg sm) { return detail::stream_update (std::move(m), std::move(sm)); },
[&](msg::ToolMsg tm) { return detail::tool_update (std::move(m), std::move(tm)); },
// … nine more domain arms …
}, msg);Each update/<domain>.cpp recompiles only when its own leaves change.
Call sites still build a Msg directly via std::variant's converting
constructor — only the owning domain accepts a given leaf, so the wrap is
unambiguous.
The Tool concept (include/agentty/tool/tool.hpp) requires a static bundle
of identity + schema + effects + behavior:
template <class T>
concept Tool = requires {
typename T::Args;
typename T::Result;
{ T::name() } -> std::convertible_to<std::string_view>;
{ T::description() } -> std::convertible_to<std::string_view>;
{ T::input_schema() } -> std::convertible_to<nlohmann::json>;
{ T::effects() } -> std::convertible_to<EffectSet>;
} && requires(const nlohmann::json& args) {
{ T::execute(args) } -> std::convertible_to<ExecResult>;
};Tools are fully typed internally; only the dispatcher boundary speaks JSON.
DynamicDispatch looks a tool up in the registry, executes it inside a
try/catch (a crashing tool becomes a typed ToolError, not a process abort),
and applies a per-tool output budget so a runaway read/bash/grep
can't blow the context window. Truncation is UTF-8-safe and comes in three
strategies:
- Head — keep the front; right for ordered chunks (read, edit, write).
- Tail — keep the end; right for log streams (bash, diagnostics).
- HeadTail — keep both ends with a middle elision marker; right for tools where both ends carry signal (grep, web_*, git diff/log/status).
The shipped tools: read, write, edit, move, remove, bash,
process_start/process_poll/process_stop, grep, glob, list_dir,
repo_map, find_definition, search_structural, web_fetch, web_search,
todo, diagnostics, test, git_status, git_diff, git_log,
git_show, git_blame, git_commit, remember, forget, wipe_memory,
search_docs, search_code, task (subagent dispatch), skill (load a
skill body on demand).
The tree-walking tools (grep, glob, list_dir, repo_map,
find_definition, the @-file picker, the symbol index) share one directory
skip-list (should_skip_dir: .git, node_modules, build*,
cmake-build*, _deps, target, vendor, .venv, …) so generated and
fetched trees never flood results. grep/find_definition prefer ripgrep
when present and pass that same skip-list as -g '!…' excludes, so both the
ripgrep and built-in backends prune identically — a cold-cache win (rg no
longer stat + gitignore-checks tens of thousands of build files) and a
quality win in repos with no .gitignore (build artifacts stop polluting
hits).
Every tool declares an EffectSet over four bits: ReadFs, WriteFs, Net,
Exec. The active Profile plus that effect set feed the pure constexpr
function policy::permission(effects, profile) in tool/policy.hpp, which
returns Allow or Prompt. The rule:
| Profile | Pure | ReadFs | WriteFs | Net | Exec |
|---|---|---|---|---|---|
| Write | Allow | Allow | Allow | Allow | Allow |
| Ask | Allow | Allow | Prompt | Prompt | Prompt |
| Minimal | Allow | Prompt | Prompt | Prompt | Prompt |
Write is fully autonomous. Ask trusts read-only inspection so an agent
loop's read/grep/glob doesn't prompt on every step but gates anything that
mutates state, runs code, or hits the network. Minimal prompts for every tool
that touches the outside world and auto-allows only pure ones. Exec is the
maximal capability — a tool carrying it prompts regardless of what else it has,
on the type-theoretic claim that bash lets the model author the side
effect, so it dominates any individual filesystem mutation already gated.
The whole table is proved at compile time. EffectSet is a 4-bit bitset (16
sets) × 3 profiles = exactly 48 cells. A second function,
expected_decision, re-states the policy independently, and an exhaustive
constexpr loop static_asserts permission(e, p) == expected_decision(e, p)
over every cell — so a one-handed change to either side breaks the build, not
a test nobody runs. A further static_assert pins the bitset width, firing if
a fifth Effect is added without extending both sides.
DynamicDispatch::needs_permission is the single place the runtime asks "does
this gate on the user?", and unknown tools fail closed (default to requiring
permission). The companion policy::reason supplies the one-line explanation
rendered in the permission card ("wants to run an arbitrary subprocess", "will
modify files on disk", …).
The same EffectSet that drives permissions also decides whether two tools may
run concurrently. effects::is_parallel_safe(active, want) answers "may a tool
with want effects start while active effects are in flight?":
WriteFsandExecdemand exclusive access. A write can mutate state a sibling is reading, writing, or shelling against — two edits to "different" files look independent until the model picks overlapping paths.Execis worse still because the model chose the command, so the runtime serialises.Pure,ReadFs, andNetcompose freely. Read-read never races,Nettouches neither FS nor process state, and in-memoryPuretools (todo) operate on data the model can't observe concurrently.
The rule is, again, proved at compile time — effects.hpp carries a block of
static_asserts pinning the exclusive/compose decisions, and the tool spec
carries parallel_rule_is_well_founded. Effects are chosen by what the tool
does to the world, not how it's implemented: git_status is ReadFs even
though it shells out to git, because the runtime knows what that subprocess
does; bash is Exec because the model picks the command.
A turn is not a single request — it cycles Streaming → AwaitingPermission → ExecutingTool → Streaming → … → Idle. domain/session.hpp models this as a
phase variant where the per-turn Active context (cancel token, start stamp,
retry counters) lives inside every non-Idle alternative — so reading those
fields from Idle is a type error, not a logic bug masked by zero defaults.
Legal transitions take the source by && and re-wrap its context in the
destination, so the FSM itself carries the turn state across phases.
Reliability rides on two independent pieces:
- A retry state machine (
retry::Fresh / StallFired / Scheduled) replaces what used to be two hand-synchronised bools. A 120-s stall watchdog trips the cancel token (Fresh → StallFired); the syntheticStreamErrorschedules a retry viaCmd::after(→ Scheduled); a second error during the wait can't schedule a duplicate;RetryStreamfiring returns toFresh. - Two independent retry budgets.
truncation_retriescovers a stream that EOFs mid-tool-args;transient_retriescovers 5xx / network / overloaded / 429.transient_retriesis not monotonic per turn — it resets to 0 whenever the wire proves healthy (first content delta, or an SSE ping / thinking delta), so a connect-ping-stall sequence gets a fresh budget each attempt instead of latching the session terminal.
A long thread eventually has to be summarized so it fits the model's context window. Two things make this cheap AND rarely-needed instead of a recurring tax:
- The trigger rides DEEP — 95 % of the window — and compaction is the
expensive event, so we fire seldom.
StreamState::compaction_threshold()(domain/session.hpp) fires atmin(kSoftFillPct% of context_max, context_max - kMinOutputHeadroom)=min(95 %, window - 20K). A 200K window fires at 180K (the 20K reply floor binds); a 1M window rides all the way to 950K. Counter-intuitively this is also the cheapest policy: the repeated conversation prefix is a prompt-cache HIT across turns (the 1-hour Anthropic anchor breakpoint inwire_body.cpp, the pinned OpenAIprompt_cache_key), so a large live prefix costs ~10 % of fresh input per turn — while each compaction runs a summary request AND resets the cache (the next turn is a full miss). Firing at 95 % instead of early means ~4× fewer of those expensive events on a big window → lower total token burn. There is no user knob (the old Compaction depth command is gone); the policy is chosen to give maximum context and minimum burn automatically. - The summarised slice is bounded so the summary request stays cheap even
on a huge window.
wire_messages_for_compaction(cmd_factory.cpp) hands the summariser the smaller of ~65 % of the window andkCompactionSliceCap(~150K) of the most recent transcript, keeping the first user turn so the original task framing survives. A cheap model compresses 150K of recent history far better — and cheaper — than 650K in one shot. - Idle cache-lapse pre-compaction — the one guard against a price spike.
Deep-ride is cheap only while the prompt cache holds the prefix. The one
case it would cost you: ride to 950K, walk away past the cache TTL (~1h),
and your next message re-prices all 950K at full input rate. So the
Tickhandler (update/meta.cpp) watches for it:should_compact_on_idle()(domain/session.hpp) fires a compaction PRE-EMPTIVELY once the session has been idle ~48 min (kIdleCompactAfter, 80 % of the TTL) AND the prefix is large (≥kIdleCompactMinTokens, 200K). The summary runs while the prefix is still a warm cache hit (cheap), so you return to a small warm context instead of eating a cold re-price. It is bounded to large + near-TTL-idle states, so ordinary pauses never trip it — this is the only proactive early-compaction path. - The summarization request itself runs on the cheapest capable model on
the active provider (the same
cheapest_capable_modelrouter subagents use — see §8.5.1), not the flagship model you're chatting with. Compacting used to mean sending the ENTIRE conversation prefix plus a verbose summarize prompt to Opus/Sonnet at full input price on every trigger — the single biggest hidden cost in a long session. Now it costs a Haiku-class summary. - The 1M context window is an explicit, entitlement-gated model variant,
not an auto-detected tier probe — matching Claude Code's own model catalog
(verified against its shipped binary). Anthropic's
/v1/modelslist is augmented with a<model>[1m]companion row per 1M-capable model on the OAuth (Pro/Max) path; the[1m]marker is a picker-only signal (ModelCapabilities::extended_context_1m) that widenscontext_window()and requests thecontext-1m-2025-08-07beta — it is stripped (wire_model_id()) before the model field ever reaches the wire.
task subagents already avoid the same trap: read-only roles (explorer,
reviewer) route to the cheapest model on the active provider that still
passes a capability floor (tool support, non-embedding/image/audio asset,
non-Weak tier); tester/coder/general keep the parent model since they
mutate the workspace. ModelCapabilities::tier() derives strength
provider-RELATIVELY from the id (family lane + weak-tool-use signal) since no
vendor ships a comparable power number — it only orders models WITHIN one
provider, never across. The router never routes up and keeps the parent
model when nothing cheaper qualifies, so a single-model or Opus-only account
sees no behavior change.
- Two roots: access boundary vs active project. agentty keeps these
distinct. The access boundary (
util::workspace_root()) is the security gate: filesystem tools refuse any path outside it. It defaults to the launch directory and is widenable —--workspace DIRmoves it, and--workspace /opts out entirely (whole-disk power). The active project (util::project_root()) is the process cwd (agentty neverchdirs away from the launch dir) clamped inside the boundary. Relative tool paths and repo/project-scoped defaults resolve against the project, not the boundary, so under--workspace /a model'sread src/foo.cppstill lands in the launched project rather than at/src/foo.cpp. By default the two are the same directory, so ordinary launches see no difference; the split only matters when the boundary is widened past the project. Everything that means "the project" —normalize_path(relative-path anchor),grep/glob/list_dirdefaults,repo_map,find_definition,diagnostics/test(build dir + manifest), git tools (default_git_start), checkpoints, the @-file picker, the symbol index, and project-scopedremember— routes throughproject_root(); everything that means "the security gate" (containment checks, sandbox bind-mounts, refuse-delete-root) usesworkspace_root(). - Sandbox.
bashanddiagnosticsrun insidebwrap(Linux) orsandbox-exec(macOS) by default. Workspace + system libs + network are reachable;~/.ssh,/etc, and other projects are read-only. An approvedbashcall still can'tcat ~/.ssh/id_rsa.--sandbox auto|on|off. - TLS pinning. Certificates are pinned on the real upstreams, end-to-end, including through the airgap SOCKS tunnel.
- Atomic writes. Every persisted file is
write+fsync+rename(or the WindowsMoveFileExWequivalent), so a crash mid-write never corrupts a thread or the credential store.
Idle agentty costs zero CPU: fps = 0 means maya only renders on a Msg,
input, or timer tick. Two host-side optimizations keep it cheap under load:
visual_hashmixes only the axes that change what's on screen. When the hash matches the previous frame,view+ render are skipped entirely. The hash hashes only the live message tail (the frozen scrollback prefix is immutable archaeology), samples long strings instead of hashing every byte, and buckets time-driven animations so each visible step — and only each visible step — advances the hash. The animation bucket is phase-locked to whatever is actually on screen so the render gate and the animation never beat against each other.needs_warmupfires a one-shot off-wire render when a thread is resumed, converting the first visible frame of a tool-heavy thread from O(content) to O(blit).
- Requires GCC 14+ / Clang 18+ / MSVC 14.40+ and CMake 3.28+ (C++26).
-DAGENTTY_STANDALONE=ONstatically links OpenSSL + nghttp2 + libstdc++ + libgcc when their.aarchives are present; libc stays dynamic. A musl toolchain with-DAGENTTY_FULLY_STATIC=ONyields a 100% static binary.-DAGENTTY_USE_MIMALLOC=ON(default) routesmalloc/freeand globalnew/deletethrough mimalloc. CMakeFetchContentfollows upstreammainand checks for updates on every configure; no mimalloc source or submodule is stored in this repository. mimalloc is compiled as a static C library with its C and C++ allocation overrides enabled.release_to_kernel()callsmi_collect(true)at coarse memory-release boundaries.- Gotcha:
AGENTTY_AUTO_PULL_MAYA=ONis the default and runsgit reset --hard origin/masteron themaya/submodule during build. Its only guard checks for uncommitted changes, so committed local maya work still gets wiped. Build with-DAGENTTY_AUTO_PULL_MAYA=OFFwhen iterating on maya.
main.cpp resolves credentials and installs a Provider + Store behind the
Deps seam, then hands control to maya. maya calls view(model) to paint and
update(model, msg) for every event. User input and SSE chunks become Msgs;
the reducer dispatches each to a per-domain handler that returns the next
Model plus a Cmd describing any side effects. Tools run behind a JSON
dispatch edge with a constexpr permission gate and OS-level sandboxing, and
their results loop back in as more Msgs. Nothing in the loop mutates global
state; the only escape hatches are the explicit Cmds the runtime executes on
your behalf.