agentty

August 9, 2026 · View on GitHub

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.


1. The shape of the program

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:

HookMeaning
initLoad settings + recent threads via Store seam.
updateThe reducer — src/runtime/app/update.cpp.
viewModel -> Element.
subscribeTimers and the live stream subscription.
visual_hashRender-skip gate; identical hash → skip frame.
needs_warmupOne-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.


2. Directory layout

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 in id.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 composed Model plus UI-only sub-states (composer, pickers, palette, modals) that belong to no domain.
    • msg.hpp — the Msg sum, split into domain sub-variants (see §4).
    • app/update/<domain>.cpp — per-domain reducers.
    • view/ — the Model -> Element pipeline, one file per widget family.
  • provider/ — the Provider concept and its implementations: anthropic/transport.cpp (HTTP/2 + SSE, the OAuth/Pro/Max default) and openai/transport.cpp (any OpenAI-compatible endpoint — openai, groq, openrouter, together, cerebras, ollama, or a raw host). selection.cpp resolves which one a --provider flag / persisted setting picks.
  • tool/ — the Tool concept, the registry, the permission policy, and one file per tool under tool/tools/. memory_store.cpp backs remember/forget.
  • 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 seamstream(Request, EventSink).
  • Store seamsave_thread, load_threads, load_thread, load_settings, save_settings, new_thread_id, title_from.
  • Auth context — the typed AuthHeader for 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).


4. Msg: a closed sum, split for compile speed

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.


5. Tools: typed bundles behind a JSON edge

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, find_references, 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).


6. Permission policy: a constexpr matrix

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:

ProfilePureReadFsWriteFsNetExec
WriteAllowAllowAllowAllowAllow
AskAllowAllowPromptPromptPrompt
MinimalAllowPromptPromptPromptPrompt

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", …).


7. Tool scheduling: parallel-safety from the effect set

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?":

  • WriteFs and Exec demand 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. Exec is worse still because the model chose the command, so the runtime serialises.
  • Pure, ReadFs, and Net compose freely. Read-read never races, Net touches neither FS nor process state, and in-memory Pure tools (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.


8. The streaming turn: a phase FSM with a retry watchdog

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 synthetic StreamError schedules a retry via Cmd::after (→ Scheduled); a second error during the wait can't schedule a duplicate; RetryStream firing returns to Fresh.
  • Two independent retry budgets. truncation_retries covers a stream that EOFs mid-tool-args; transient_retries covers 5xx / network / overloaded / 429. transient_retries is 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.

8.5. Context management: smart compaction, not a fixed cliff

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 at min(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 in wire_body.cpp, the pinned OpenAI prompt_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 and kCompactionSliceCap (~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 Tick handler (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_model router 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/models list 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 widens context_window() and requests the context-1m-2025-08-07 beta — it is stripped (wire_model_id()) before the model field ever reaches the wire.

8.5.1 The subagent cost router

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.


9. Safety boundaries

  • 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 DIR moves it, and --workspace / opts out entirely (whole-disk power). The active project (util::project_root()) is the process cwd (agentty never chdirs 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's read src/foo.cpp still 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_dir defaults, repo_map, find_definition, diagnostics/test (build dir + manifest), git tools (default_git_start), checkpoints, the @-file picker, the symbol index, and project-scoped remember — routes through project_root(); everything that means "the security gate" (containment checks, sandbox bind-mounts, refuse-delete-root) uses workspace_root().
  • Sandbox. bash and diagnostics run inside bwrap (Linux) or sandbox-exec (macOS) by default. Workspace + system libs + network are reachable; ~/.ssh, /etc, and other projects are read-only. An approved bash call still can't cat ~/.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 Windows MoveFileExW equivalent), so a crash mid-write never corrupts a thread or the credential store.

10. Rendering performance

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_hash mixes 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_warmup fires 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).

11. Build notes

  • Requires GCC 14+ / Clang 18+ / MSVC 14.40+ and CMake 3.28+ (C++26).
  • -DAGENTTY_STANDALONE=ON statically links OpenSSL + nghttp2 + libstdc++ + libgcc when their .a archives are present; libc stays dynamic. A musl toolchain with -DAGENTTY_FULLY_STATIC=ON yields a 100% static binary.
  • -DAGENTTY_USE_MIMALLOC=ON (default) routes malloc/free and global new/delete through mimalloc. CMake FetchContent follows upstream main and 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() calls mi_collect(true) at coarse memory-release boundaries.
  • Gotcha: AGENTTY_AUTO_PULL_MAYA=ON is the default and runs git reset --hard origin/master on the maya/ submodule during build. Its only guard checks for uncommitted changes, so committed local maya work still gets wiped. Build with -DAGENTTY_AUTO_PULL_MAYA=OFF when iterating on maya.

12. One-paragraph mental model

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.