Architecture Overview
Spettro is a Go application with a Bubble Tea TUI front-end and internal service packages.
Entry point and runtime
cmd/spettro/main.goinitializes config, encrypted keys, provider manager, model catalog, manifest validation, and TUI.internal/tuiis the active runtime: command dispatch, dialogs, rendering, approval flows, and agent execution.- Project manifest loading is handled by
internal/config(LoadAgentManifestForProject). Every front-end loads it once, inbootstrapSession(cmd/spettro/bootstrap.go); the TUI host hands that copy totui.Newwithtui.WithManifest, so the file is not parsed twice before the first frame. - Model lists that need the network (local endpoint probes, the Spettro Subscription list, the catalog refresh) arrive in the background (
startModelDiscovery,models.LoadAndRefresh). Each change is applied to the provider manager first and then signalled on a one-slot channel (modelsChangedSignal); the TUI waits on it (tui.WithModelUpdates) and refreshes an open/modelsselector or onboarding picker, keeping the cursor on its model, and redraws the header. The idle TUI runs no timer, so without the signal a model that arrived in the background would appear only after the next keystroke.
Core packages
internal/tui: interactive terminal UI, command handling, approvals, and session interactions. Every frame has to fit the terminal exactly: rows are fitted to their width before lipgloss sees them (a box that wraps a long row grows taller than the layout reserved), andrecalcLayoutmeasures the rendered input area instead of estimating it. The todo/agent footer is the one flexible block:parallelFooterBudgetcaps it by the rows the rest of the frame needs, so it is what shrinks on a short terminal. Tool labels are chosen by tool name; a name that belongs to a tool of the operator's own (AgentManifest.UserTool) gets the generic label instead of the built-in's (ACP cards follow the same rule). The counted wording of a group of calls ("Read 3 files", "Running 2 commands…") comes from one table,toolWordingsintool_wording.go, which a test keeps complete for every built-in and retired name; the file tools' labels and diffs acceptfile_pathas well aspath.internal/tui/render_fit_test.gorenders huge tool calls, approvals and dialogs at sizes from 40x15 up and checks every frame;scripts/tui-e2e/drives the real binary in a pseudo-terminal against a fake OpenAI-compatible server and checks what reaches the screen (see its README).internal/termtext: makes untrusted text safe for the cell grid.SanitizeLineshows tool output the way a terminal would (escape sequences removed, tabs expanded, carriage returns resolved to the final state of the line);EscapeControlsshows text the user must judge (a command waiting for approval, a diff) with every control character, invisible character (Hidden: Unicode format characters such as bidi controls and zero-width characters, variation selectors, default-ignorable fillers, and every space other than the ASCII one) and invalid byte visible instead;EscapeExactalso shows a tab as⇥and a line-ending carriage return as^Mrather than as spaces and nothing, andEscapeLinesapplies it to multi-line text leaving the terminal (ACP, Telegram, the remote event's*_visiblefields); both replace text that is not valid UTF-8 before a stray byte can act as a C1 control;Fit,FitLeftandWrapbound text to a number of cells, marking cuts with…;HardWrapbreaks text into rows of a number of cells keeping every character (spaces included, and never at a row's edge where they could not be seen), for the approval dialog and review, which must never cut or drop anything.SanitizeLinealso appliesStableWidth, which rewrites grapheme clusters whose width terminals disagree on (emoji ZWJ sequences, the emoji variation selector, skin tones, keycaps, flags) into a form every terminal draws at the width the layout measured. Only emoji sequences are rewritten; text in other scripts is kept byte for byte.internal/agent: LLM runtime loop, native tool-call execution, delegation, policy checks, and tool output spooling (large results fromfile-read,grep,bash,web-fetchetc. are written to a session-scoped spool file with a truncated head and a pageable offset, so the model can retrieve the full content viatool-outputwithspool:NIDs).internal/config: config persistence, encrypted keys, trust list, manifest parsing/validation/migration.internal/provider: provider adapters, endpoint resolution, connected model routing, and Fantasy-backed text model execution with legacy SDK fallback for vision or legacy completion endpoints.internal/models: fetch/cache ofmodels.devcatalog.internal/session: persistent session storage (messages,tasks,agentsevents) and resume support.internal/storage: project/global.spettrodirectory setup.internal/hooks: global/project hook loading, merge, and execution.internal/compact: context usage policy and compaction guardrails.internal/workflow: the workflow script engine — a goja JavaScript runtime withagent/parallel/pipeline/phase/log/budget/size/checkpointglobals and theplan/untilDryhelpers, an event loop that resolves agent promises from goroutines, a run handle that can pause a run at a checkpoint and resume it from a later tool call, meta-header parsing andparamschecking, structured-output validation, and the journal (agent calls and checkpoint answers) that makes a run resumable. It knows nothing about Spettro's agents: sub-agent execution arrives through aRunnerinterface (implemented ininternal/agent/workflow.go) and progress leaves through anObserver, so the engine is testable without a provider. The registry of runs paused at a checkpoint lives with the agent glue (internal/agent/workflow_live.go), owned by the host session.internal/skills: Agent Skills discovery, parsing, install/uninstall, prompt rendering, argument substitution and$namementions, plus the catalog cache, which rescans only when a skill folder orSKILL.mdchanged on disk. DiscoversSKILL.mdpacks from.spettro/skills/(project, walking up to the repository root, and~) and, read-only, from the Claude Code and Codex folders (.agents,.claude,.codex,.openai) so their skills work without conversion. Hosts get the catalog throughagent.SkillCatalogFor. See skills.md.internal/testhome(tests only): keeps test binaries off the real~/.spettro(config, keys, Telegram settings,lsp.json). A package whose tests can reach per-user files callsos.Exit(testhome.Main(m))fromTestMain: HOME, USERPROFILE and the XDG/AppData directories point at a temporary directory and SUDO_USER is cleared, while GOENV, GOCACHE, GOPATH, GOMODCACHE and GOPLSCACHE stay on their real locations. Optional setup functions passed toMainprepare the temporary home before the tests run. The package carries aTestHomeIsIsolatedtest callingtesthome.Check, which fails when that isolation is missing. A test that writes to the home (anlsp.json, a config) should stillt.Setenv("HOME", t.TempDir()): the temporary home is shared by the whole test binary.TestEveryTestPackageIsolatesHome(ininternal/testhome) walks the module and fails for any test package that neither callstesthome.Mainwith thetesthome.Checkguard nor appears in its shortpureTestPackageslist of packages that cannot reach per-user files (no home, config or storage dependency and no child processes), so a new test package cannot forget the isolation.
TUI render pipeline
The transcript and the frame around it are built so that the work per frame does not grow with the length of the session:
- Render cache (
render_cache.go). Each chat message is rendered once into rows and cached by its id; a refresh reuses an entry while the message's fields equal the snapshot taken when it was rendered (string equality, a pointer comparison when nothing changed) and the layout it depends on (pane width, and the ctrl+o/ctrl+g toggles for messages with tool calls) still holds. A message keeps the accent colour of the mode it was written in, so a mode switch re-renders nothing. A message with a running pty tool is never cached (its live tail comes from the pty session). - Frame budget. When many messages need rendering at once (a resize,
ctrl+o, ctrl+g, a theme switch,
/resume), a refresh renders for about 12 ms, newest first, and leaves the older messages' previous rendering (or a one-row placeholder) in place;renderFillMsgcontinues on the following frames until the transcript is complete. The scroll height is approximate until then; a view following the bottom stays at the bottom, and a view scrolled up stays on the message it showed (see the viewport below). - Live drafts. The streamed answer and thinking are rendered incrementally: completed lines (outside a code fence or table) are rendered once, and only the line being written is rendered per token. The final message replaces the draft at run end and is rendered whole.
- Transcript viewport (
lineview.go). The viewport holds the rendered blocks as row slices with per-block offsets and draws only the rows on screen; content is wrapped to the pane width before it gets there. It follows the latest output only while it is at the bottom; submitting a prompt jumps to the bottom. A view scrolled up keeps showing the same place through a refresh that re-renders the blocks above it (a resize, the side panel, ctrl+o/ctrl+g, the frame-budget fill): refreshViewport anchors the top row to its block and the row within it, scaled when the block was rewrapped to a new width. Building with-tags spettro_bubblesviewportswaps in the previousbubbles/viewportfor comparison (one release). - Run events (
run_events.go). Stream chunks and tool traces travel from the agent to the UI through one unbounded, ordered queue; the agent never blocks on rendering, and the UI applies whatever accumulated since the last frame as one batch (one refresh, one frame). The run's done message is sent only after the UI applied the run's last events. - Frame memo (
frame.go). The header, input box, status bar, side panel and the delegation/todo footer are kept between frames and re-rendered only after a message that can change them; a streamed token or an animation tick redraws the transcript rows and the working indicator only (a tick also redraws the chrome parts that animate, such as a glaring task). The frame is joined from rows measured once. - Idle. The 60 Hz animation tick runs only while something animates
(the startup eyes intro, a run, onboarding and sign-in spinners, the MAX
plan label, a goal's clock, running delegations, in-progress tasks, glowing
input keywords); the idle eyes blink runs on its own slow tick chain until
the first user message; a 1 s
clock tick redraws the status bar while a
/loopcounts down or a background job or pty session runs (either can end with no message reaching the TUI); banners clear with a one-shot timer; the input cursor is steady unlesscursor_blinkis set. Otherwise an idle TUI does no work between keystrokes. - Off the Update goroutine. The side panel's git state (
git status,git diff --numstat),/diff(which also hands the modified files it lists to the side panel), a goal iteration's workspace fingerprints (taken in the run's command before and after the run), and the save of the mode and side panel toggle toconfig.jsonrun as background commands.TestNoGitOnTheUpdateGoroutinecounts git processes started bytui.Newand by Update. - Side panel. Only the rows in the visible window are styled, the list
reads the activity feed in place instead of copying it, the frame is drawn
by hand (byte for byte what the lipgloss border style drew, checked by
TestSidePanelBoxMatchesLipgloss), and the activity feed keeps the newest 2,000 items (the subtitle counts the rest).
Agent manifest
Spettro loads spettro.agents.toml from project root when present; otherwise it uses built-ins.
See AGENTS.md for schema details (version = 14, [runtime], [[tools]], [[agents]], permissions, validation, and the migrations that bring an older manifest up to date).
Execution flow
- User prompt enters current active agent (
planby default). - Agent emits native tool calls via the provider API (parallel-capable via multiple calls per response).
- Runtime executes allowed tools per manifest and permission policy.
- Plans can be queued and executed via
/approvethroughcoding. - Outputs, tool traces, and session events are appended to timeline/session storage.
Orchestration contract (orchestrators vs workers)
Spettro deliberately splits the agent roster into orchestrators (plan, coding, ask) and workers (explore, code, git, test, review, docs, general-purpose). The orchestration contract is:
- Orchestrators are coordinators. They decompose the user's request and spawn workers via the
agenttool, preferring parallel batches (the runtime allows up to 4 concurrent sub-agents per step). Their prompts inagents/planning.md,agents/coding.md, andagents/chat.mdenforce "delegate first". planis enforced at the manifest level: it has no direct read tools (glob/grep/file-read). Discovery must go through anexploreworker. The corresponding contract tests live intests/config/manifest_test.go.codingkeeps its raw write/exec tools as an emergency escape hatch, but the prompt strongly discourages using them directly. The expected default path iscoding → {explore, docs}(parallel)→ code(impl)→ {test, review}(parallel)→ git.- Workers are individual contributors.
agents/code.mdis the dedicatedcodeworker prompt; the orchestrator-styleagents/coding.mdis used only by thecodingorchestrator. Workers do not re-delegate (andcodeis the only worker that has theagenttool, gated by handoffs). general-purposeis the fallback worker: every other worker covers one slice, so an open-ended subtask that mixes discovery, change, and verification had to be split by hand. It holds the read/write/execute surface those specialists split between them, and its prompt lives inagents/general-purpose.md.- The runtime's
agentdispatch already validates role + handoff compatibility (isDelegationRoleAllowedininternal/agent/llm_runtime_shell.go), so workers can't accidentally spawn an orchestrator. - Each delegated run is bounded by the
agenttool'stimeout_sec. A worker that can write files or run commands (file-write/file-edit/multi-edit/shell-exec/bashamong its tools) gets 900s while the manifest still carries the shipped 300s; any other value set by the user applies as written. At 80% of the limit the worker is told to stop starting new work and report. A worker that times out or fails does not lose its work: the parent gets a"status":"timed_out"(or"failed") report carrying its last message,files_modified, the shell commands it ran and its last tool results, and its changes stay on disk (or on its preserved branch underisolation: "worktree"). Seeinternal/agent/subagent_timeout.go.
Provider abstraction
- Streamed requests to OpenAI-compatible chat-completions endpoints (catalog providers with an OpenAI-style API, the Spettro Subscription, local servers) go through Spettro's own client in
internal/provider/wire/chatcompletions(encoder, SSE reader, chunk decoder, tool-argument tracker) driven byinternal/provider/native*.go. It sends the same request JSON as fantasy (pinned by golden tests) but caches each message's encoding across the steps of a run, so a step re-encodes only what it added (one cache lane per conversation, so the main agent and up to 47 concurrent sub-agents each keep theirs; 8 MB in all; a request of a single message, such as a compaction summary, takes no lane; a lane unused for 5 minutes is released; images go into the body straight from the media cache without being copied), and it decodes streamed tool-call arguments in linear time."provider_wire": "fantasy"inconfig.jsonorSPETTRO_PROVIDER_WIRE=fantasyswitches back; an encoder failure falls back to fantasy on its own. - Everything else (Anthropic-protocol providers, the official
openaiprovider, non-streamed requests) routes through Charm'sfantasySDK. Both streaming paths feed the same collector (stream_collect.go), so replies, finish reasons, usage and errors come out identical, apart from the few malformed replies the native client accepts where fantasy fails (listed under Provider wire). - Legacy completion-only backends fall back to Spettro's direct SDK adapters, which use the same OpenAI and Anthropic SDK copies fantasy links.
- Per-request model metadata (vision, tool calling, context window, output limit) comes from an index rebuilt whenever a model list changes (
Manager.Lookup), and attached images and tool schemas are cached across requests (request_cache.go; images up to 6 MB, each kept only in the forms requests used and released after 5 minutes without use). - Known provider base URLs and local endpoints still resolve through the same manager layer.
- Catalog-backed model lists are preferred; fallback models are used when catalog is unavailable.
