Skip to content
yottacode v0.4.0 is out! πŸŽ‰ See the release notes β†—
Architecture

Architecture

yottacode is deliberately small and layered: one agent loop, one event channel, two user interfaces, and a set of structured tools.

Core Data Flow

                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚   User Input         β”‚
                β”‚  (CLI or TUI)        β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚    TUI / Oneshot     β”‚
                β”‚   (Consumer)         β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          events ◄──────── β”‚ decisions β”‚
                           β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚   agent.Turn         β”‚
                β”‚  (Main Loop)         β”‚
                β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
                β”‚  β”‚ Tool Registry β”‚β—„β”€β”˜
                β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
                β”‚          β”‚           β”‚
                β”‚          β–Ό           β”‚
                β”‚    Adapter Stream    β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  Model Provider API  β”‚
                β”‚  (OpenAI-compatible) β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Package Layout

cmd/yottacode/                cobra root command + run subcommand

internal/
  cli/                        ChatOptions resolution
  adapter/                    OpenAI-compatible streaming + OpenAI Responses routing
  agent/                      Turn loop, tool registry, approvals, write-path validation
  session/                    Session persistence and resume logic
  memory/                     USER.md / YOTTACODE.md loading, agent-managed memory store, retrieval orchestrator
  recall/                     SQLite + FTS5 indexing for /recall
  tui/                        Bubble Tea interface, approval UI, slash commands
  oneshot/                    non-interactive `yottacode run` consumer
  version/                    version string

Core Runtime Model

internal/agent.Turn is the only part of the system that talks to the model and executes tools. Everything else either prepares its inputs or consumes its events.

user input
   |
   v
consumer (tui or oneshot)
   |                 ^
   | events          | decisions
   v                 |
agent.Turn --------------------> tool registry
   |
   v
adapter stream

This split is the reason the TUI and yottacode run can share nearly all of the execution stack.

Event Flow

The agent emits typed events such as:

  • streamed assistant content
  • streamed reasoning updates
  • approval requests and auto-approval notices
  • tool start and tool result messages
  • iteration-cap warnings
  • terminal completion or error events

Consumers decide how to render those events. The Bubble Tea UI turns them into transcript rows, status changes, and approval modals. The one-shot runner sends answer content to stdout and operational detail to stderr.

Assistant content that arrives before a tool call is provisional. If the final assistant message contains tool calls, the loop drops that pre-tool text from history and the TUI discards its buffered preview, leaving the transcript to show only intentional assistant replies plus tool lifecycle cards/status events. During active TUI turns, streamed reasoning and provisional answer text share a live preview above the cmdline only while there is content to show. Tool-only work does not reserve blank preview rows, so the plan card and thinking row stay compact while fresh transcript output continues to follow the bottom unless the user intentionally scrolls away.

Session Lifecycle

yottacode

Running yottacode with no subcommand starts the interactive TUI directly.

Startup flow:

  1. Parse flags and resolve environment variables.
  2. Open or resume a session.
  3. Load USER.md, YOTTACODE.md, and any agent-managed memories under user and project scope.
  4. Build the model adapter and tool registry.
  5. Start the Bubble Tea program and hand user turns to agent.Turn.

yottacode run

yottacode run "<prompt>" uses the same core loop, but without the TUI.

  • Prompt input comes from the CLI argument or stdin.
  • Assistant content is written to stdout.
  • Reasoning, tool status, and errors are written to stderr.
  • Approval-required tool calls fail unless an allow rule in .yottacode/permissions.json matches them, or --yolo is set (DANGEROUS).

Tools And Safety Layers

The agent exposes twenty-eight structured tools in tools.md. Two independent safety systems gate every model-emitted call:

  • Permissions (internal/permissions/) β€” project-local .yottacode/permissions.json (committable) and .yottacode/permissions.local.json (gitignored) carry pattern-based allow / ask / deny rules per tool. Decision precedence is deny > allow > ask > default.
  • Write-path validation (internal/agent/writepath.go) β€” filesystem mutators (write/edit/mkdir/copy/move/delete) are confined to cwd, refuse symlinks, and refuse a hardcoded deny list of yottacode and git internal paths. apply_diff parses its diff header so each touched file goes through the same validator β€” the patch surface can’t bypass the deny list.
  • Read-path validation (internal/agent/writepath.go, ValidateReadPath + DefaultDenyReadPaths) β€” the auto-execute read tools (read_file, read_many_files, grep) refuse a narrow list of credential-bearing locations (~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, ~/.yottacode/.env, <cwd>/.env*, …) so prompt injection can’t silently exfiltrate keys. The user can still read these via run_bash, which always prompts.

There is no in-process sandbox, and there will not be one. yottacode deliberately stays out of the OS-isolation business β€” no bwrap, firejail, landlock, seccomp, or pluggable Sandbox backends to maintain β€” so the core stays small and portable. For real isolation across every tool (run_bash, write_file, git, etc.), run yottacode itself inside a container or devcontainer.

Agent modes

Two modes (mutually exclusive, control workflow shape) and one always-approve overlay (orthogonal, applies on top of any mode) sit on top of the base approval flow:

  • Plan mode β€” read-only research state. Entered via /plan, Shift+Tab, or --permission-mode plan. The model can read, search, ask, and write only to a single plan file under ~/.yottacode/plans/<slug>.md. exit_plan_mode surfaces the plan in an approval card with four hotkeys: [A] auto-approval (implement with auto mode enabled), [M] manual approval (implement, per-tool prompts continue), [L] save for later, [K] keep refining. State lives on agent.PlanModeState.
  • Auto mode β€” implementation state. Entered via /auto, Shift+Tab, or --permission-mode auto. Mutating tools auto-allow except a safety floor (run_bash, git_commit, git_checkpoint, rollback). Effective iteration cap is 4Γ— the configured MaxIterations. State lives on agent.AutoModeState.
  • Yolo mode overlay β€” drops permission prompts on all tools (no safety floor) and removes the iteration cap entirely. Entered via --yolo at startup or /yolo mid-session (mirroring Claude Code’s startup flag, with a mid-session toggle added). Sits on top of whichever mode is active; the banner shows the mode label with a ⚠ yolo mode suffix (the standalone banner reads ⚠ yolo mode). State lives on agent.YoloModeState.

Shift+Tab cycles through normal β†’ plan β†’ auto β†’ yolo β†’ normal. The same states are slash-addressable with /auto, /plan, and /yolo; /auto keeps the safety floor while /yolo is the explicit always-approve path with no safety floor.

Interrupts

Mid-turn user input is a first-class flow, not a blocked interaction: pressing Enter while the agent is thinking (streaming, calling a tool, or running a foreground subagent) captures the new message and queues it for delivery at the next tool round without cancelling the active turn. The TUI sends the message through the per-turn userMsgCh; agent.Turn checks that channel between tool-call batches and appends any delivered text to history as another user message. If the model finishes before a tool-round injection point, or if the one-message channel is already full, the TUI falls back to pendingInputAfterTurn so turnEndedMsg can auto-submit the message as the next turn. While an approval or path-trust modal is focused, the modal decision hotkeys keep priority; any other non-empty Enter queues the typed follow-up with an explicit queue receipt instead of silently discarding the draft.

Before a queued message is delivered, pressing Up on an empty mid-turn textarea recalls it into the editor and drains it from the queue. Pressing Enter after editing requeues the revised text through the same path; Esc/Ctrl+C still drop it instead of sending.

Esc and Ctrl+C are the explicit “stop without sending” surface: they cancel the turn and drop any queued message, but leave the textarea contents alone. Esc mirrors Claude Code’s cancel feel; Ctrl+C keeps the terminal-native semantics.

Synthetic tool_result policy. Mid-turn cancellation must preserve provider-valid history: every tool_use block in the just- cancelled assistant message needs a matching tool_result, or the next request fails. The agent loop handles this in three places:

  1. streamIteration accumulates streamed content tokens. On cancel, it returns a partial assistant message with the accumulated content and no tool calls (any tool-use the adapter was mid-building is deliberately dropped β€” content-only messages are valid for every provider).
  2. executeToolCall propagates ctx.Err() from a ctx-respecting tool (instead of swallowing it as an error: context canceled string), so the caller can route into the cancel branch.
  3. executeToolCalls (serial and parallel) appends "interrupted by user" tool_result entries for every orphaned call β€” both the in-flight tool that was cancelled and any queued calls that never started. Parallel workers that completed cleanly before the cancel keep their real result.

When a queued message must fall back to cancellation, history is repaired, the loop emits a TurnInterrupted event (distinct from ErrorEvent so consumers render it as a calm ↩ interrupted line, not a red error), and returns. The TUI’s auto-submit then fires the queued message into a fresh turn that sees the partial assistant content + synthetic tool results in history.

Background subagents are exempt. Their context is detached from the parent turn (context.Background(), not the parent’s ctx), so a parent-turn cancel does not propagate. They continue running to completion and surface via SubagentBackgroundDone whenever they finish, regardless of which parent turn is active.

LSP Code Intelligence

The LSP bridge adds semantic, mostly read-only code intelligence on top of the normal lexical tools (grep, glob, read_file). It is registered by default; servers are lazy-started only when a semantic LSP tool needs one. The core registry exposes LSP-backed tools for status, workspace symbols, definitions, references, diagnostics, hover, code actions, and call hierarchy.

Architecture shape:

agent tool call
   |
   v
internal/agent/lsp_* tool
   |
   v
internal/lsp Manager  -- bounded pool keyed by language + root + command
   |
   v
local language server subprocess over stdio JSON-RPC

The LSP manager is session-owned. TUI and oneshot sessions construct it, pass it into the LSP tools, and close all pooled servers on exit. Servers are lazy-started on first use and reused until they go idle or the bounded pool needs to evict one. The user can inspect pool stats through lsp_status. Interactive sessions also show a non-blocking LSP Code Intelligence advisory card when supported files are detected and a matching server is missing. The same startup detection seeds the next user turn with hidden, model-facing setup guidance so the agent can offer the matching install command when the request would benefit from LSP. The command-line yottacode doctor includes an LSP Code Intelligence section for preflight setup checks, detected languages, server availability, install hints, overrides, and manager configuration. Any install still goes through standard bash approval; no server install happens automatically.

The bridge is intentionally local and conservative:

  • yottacode never installs language servers automatically;
  • missing servers degrade into install hints and lexical fallback where possible;
  • [lsp.servers] overrides execute direct argv arrays, not shell strings;
  • code actions remain preview-first: listing is read-only, and any server-proposed edits go through explicit preview plus lsp_apply_workspace_edit rather than direct writes.

See lsp.md for setup, supported languages, commands, troubleshooting, and production-status notes.

Subagents

The Agent tool (internal/agent/agent_tool.go) is the parent’s delegation surface. When the model calls it, yottacode constructs a fresh LoopConfig that reuses the parent’s adapter, permissions, and cwd, but pairs them with a filtered tool registry, fresh inactive plan/auto mode states, a standard iteration cap, and an isolated message history seeded from the chosen agent definition’s system prompt and the user-supplied subagent prompt. agent.Turn runs recursively against that config; the child’s events flow into a runner-local channel that the parent does not consume directly β€” the runner translates only high-level activity (subagent start / progress / done) into events on the parent’s events channel, so the parent’s context window never sees the child’s reasoning or tool outputs. Only the child’s final assistant content is returned as the parent’s tool-result string. Foreground runs block the parent’s tool call; background runs (run_in_background: true, TUI-only) detach to a goroutine bound to a session-scoped context, and surface their completion via a long-lived inbox channel the TUI’s Model drains in parallel with the per-turn event stream. The child registry always excludes Agent itself (hard recursion guard, even against adversarial config) and exit_plan_mode. See subagents.md for the user-facing surface β€” agent definition format, built-in agents, /subagents command, and limitations.

Advisor / Implementer Routing

The optional [router] role-routing layer lets yottacode use two model roles during one session:

  • Advisor β€” the planning, design, and reasoning model.
  • Implementer β€” the faster coding model used for implementation-heavy work.

When routing is enabled, yottacode starts interactive sessions on the advisor so the main conversation gets the stronger planning model by default. Plan mode also switches to the advisor. Auto mode switches to the implementer, and delegated subagents plus summarization/compaction run on the implementer unless an individual agent definition pins a different model.

This is primarily a cost-control feature. It does not magically reduce the number of tokens needed for a task; instead, it moves routine isolated work onto a cheaper role while keeping high-leverage planning on the advisor. The main prompt cache is preserved except at explicit model-switch boundaries such as startup, /plan, /auto, /model, or a /advisor picker change.

Implementer-driven contexts also get a narrow consult_advisor tool. It is available to implementer subagents and to the top-level conversation when auto routing has switched the main session onto the implementer model; advisor-led sessions and plan mode do not expose it. The tool performs one isolated no-tools call to the advisor for design, debugging, or uncertainty that the implementer should not resolve alone. Because advisor calls are the more expensive path, the tool is explicit and bounded rather than an automatic fallback.

Configuration uses role-named fields under [router]:

[router]
  mode              = "auto"                       # off | manual | auto
  advisor_model     = "anthropic:claude-opus-4-6"
  implementer_model = "anthropic:claude-haiku-4-5"

advisor_models and implementer_models provide primary→fallback chains. Legacy smart_model(s) and fast_model(s) still load as aliases for advisor and implementer, but new writes use the role names. Reasoning effort stays global through /effort and --reasoning-effort; there are no per-role effort settings.

The loop reads all three flags at turn start (effective iteration cap) and on every tool dispatch. Approval-chain priority (the internal YoloModeState Go identifier still uses “yolo” in the precedence label; the user-facing banner label is “yolo mode”): Deny > yolo > plan-gate > plan-file-allow > auto-allow > Allow > Ask > tool default. See security-and-allow-lists.md for the full precedence table.

Extension Points

Most feature work lands in one of these seams:

  • Add a new tool by implementing agent.Tool and registering it in the TUI and oneshot setup paths.
  • Add a new slash command in internal/tui/commands.go.
  • Add or expand an adapter while keeping agent.Turn unchanged.
  • Add a new built-in subagent type by dropping a markdown file under internal/subagents/builtins/; //go:embed picks it up at build time without Go changes.

Provider diagnostics follow the same seam discipline:

  • static resolution and validation belong in internal/adapter
  • active probes belong in internal/adapter
  • /provider, /doctor, yottacode doctor, and oneshot preflight are thin consumers of that adapter-level API

The general rule is simple: keep UI concerns in tui or oneshot, provider details in adapter, and agent behavior in agent.