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 stringCore 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 streamThis 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:
- Parse flags and resolve environment variables.
- Open or resume a session.
- Load
USER.md,YOTTACODE.md, and any agent-managed memories under user and project scope. - Build the model adapter and tool registry.
- 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
allowrule in.yottacode/permissions.jsonmatches them, or--yolois 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_diffparses 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 viarun_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_modesurfaces 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 onagent.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 configuredMaxIterations. State lives onagent.AutoModeState. - Yolo mode overlay β drops permission prompts on all tools (no
safety floor) and removes the iteration cap entirely. Entered via
--yoloat startup or/yolomid-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 modesuffix (the standalone banner readsβ yolo mode). State lives onagent.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:
streamIterationaccumulates 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).executeToolCallpropagatesctx.Err()from a ctx-respecting tool (instead of swallowing it as anerror: context canceledstring), so the caller can route into the cancel branch.executeToolCalls(serial and parallel) appends"interrupted by user"tool_resultentries 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-RPCThe 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_editrather 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.Tooland 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.Turnunchanged. - Add a new built-in subagent type by dropping a markdown file under
internal/subagents/builtins/;//go:embedpicks 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.