Crush — Architecture#
Architectural style#
Layered monolith with an optional in-process / client-server split.
Crush is a single binary that composes five clearly-delineated layers: CLI, Workspace abstraction, Application core, Agent engine, and Persistence. Within a single process the layers call down; when the optional client/server mode is enabled (CRUSH_CLIENT_SERVER=1), the CLI calls a REST API over a Unix socket and the lower layers run in a separate detached process — but the CLI and TUI code sees no difference, because both paths satisfy the same Workspace interface.
The design is more “modular monolith” than “microservices”: all code lives in one binary and one repo, but the architectural seams are real enough that the split mode works without touching the TUI.
Component diagram (textual)#
┌────────────────────────────────────────────────────────────────────┐
│ main.go │
│ cmd.Execute() ─► cobra root/sub-commands │
└───────────────────────────┬────────────────────────────────────────┘
│ setupWorkspace()
┌─────────────┴──────────────┐
│ │
CRUSH_CLIENT_SERVER=0 CRUSH_CLIENT_SERVER=1
│ │
setupLocalWorkspace() setupClientServerWorkspace()
│ │
app.New() ensureServer() → crush server (detached)
│ client.NewClient()
workspace.NewAppWorkspace() workspace.NewClientWorkspace()
│ │
└──────────────┬─────────────┘
│ Workspace interface
┌────────┴────────┐
│ │
TUI: ui.New() run: app.RunNonInteractive()
tea.Program.Run()
│
│ ws.Subscribe(program) — event bridge
│
┌─────▼───────────────────────────────────────────┐
│ app.App (service container) │
│ ┌──────────────────────────────────────────┐ │
│ │ session.Service message.Service │ │
│ │ history.Service permission.Service │ │
│ │ filetracker.Service lsp.Manager │ │
│ └──────────────────┬───────────────────────┘ │
│ pubsub.Broker[tea.Msg] ◄── all service events │
│ ┌──────────────────▼───────────────────────┐ │
│ │ agent.Coordinator │ │
│ │ ┌────────────┐ ┌──────────────────────┐│ │
│ │ │SessionAgent│ │fantasy.Provider (LLM) ││ │
│ │ │ (run loop) │ │(anthropic/openai/etc.)││ │
│ │ └────────────┘ └──────────────────────┘│ │
│ │ tool registry (bash/edit/grep/mcp/lsp/…) │ │
│ └───────────────────────────────────────────┘ │
│ db.Connect() ── SQLite via ncruces/go-sqlite3 │
└─────────────────────────────────────────────────┘
Server mode (CRUSH_CLIENT_SERVER=1):
┌──────────────────────────────────────────────────────┐
│ crush server process │
│ server.Server (chi router, Unix socket / npipe) │
│ backend.Backend (workspace registry) │
│ csync.Map[id] → *Workspace { *app.App } │
└──────────────────────────────────────────────────────┘Core components#
Workspace interface (internal/workspace)#
- Package:
github.com/charmbracelet/crush/internal/workspace - Responsibility: The central seam between any frontend (TUI,
crush run, future IDE) and the backend. Defines ~40 methods covering sessions, messages, agent control, permissions, file tracking, history, LSP, config, MCP, and events. - Key types:
Workspace(interface),AppWorkspace(in-process impl),ClientWorkspace(HTTP client impl) - Dependencies: Implemented by
internal/app(directly) andinternal/client(via HTTP)
App (internal/app)#
- Package:
github.com/charmbracelet/crush/internal/app - Responsibility: The service container. Wires together all domain services, owns the global context, pub-sub broker, and agent coordinator. Implements
Workspaceindirectly viaAppWorkspace. Also handles graceful shutdown. - Key types:
Appstruct — fieldsSessions,Messages,History,Permissions,FileTracker,LSPManager,AgentCoordinator,events *pubsub.Broker[tea.Msg] - Dependencies:
internal/agent,internal/session,internal/message,internal/history,internal/permission,internal/filetracker,internal/lsp,internal/db,internal/pubsub,internal/skills,internal/mcp
Agent Coordinator (internal/agent)#
- Package:
github.com/charmbracelet/crush/internal/agent - Responsibility: Manages the LLM agent lifecycle. Constructs
fantasy.Providerinstances for each configured LLM backend, builds the tool list (including MCP and LSP tools), assembles and wraps tools with hook interception, and drivesSessionAgent.Run()turns. Also handles sub-agent spawning, token budget management, and skill discovery. - Key types:
Coordinator(interface),coordinator(impl),SessionAgent(interface),SessionAgentCall(value type for a single LLM turn),Model(wrapsfantasy.LanguageModel+ catwalk config + user config) - Dependencies:
charm.land/fantasy(LLM abstraction),internal/session,internal/message,internal/permissions,internal/history,internal/filetracker,internal/lsp,internal/hooks,internal/skills
Fantasy LLM abstraction (charm.land/fantasy)#
- Package: external —
charm.land/fantasy+charm.land/fantasy/providers/* - Responsibility: Provider-agnostic interface over Anthropic, OpenAI, Azure, Bedrock, Google Gemini, OpenRouter, Vercel AI, and OpenAI-compatible providers. The coordinator calls
provider.LanguageModel(ctx, modelID)to get afantasy.LanguageModel, then passes it toSessionAgent. - Key types:
fantasy.Provider,fantasy.LanguageModel,fantasy.AgentTool,fantasy.AgentResult,fantasy.ProviderOptions - Dependencies: Per-provider SDK clients (charmbracelet forks of openai-go, anthropic-sdk-go, etc.)
Pub-Sub event bus (internal/pubsub)#
- Package:
github.com/charmbracelet/crush/internal/pubsub - Responsibility: Generic fan-out broadcast bus.
App.setupEvents()subscribes to each domain service’s event channel and re-publishes through a singleBroker[tea.Msg]. The TUI subscribes to this broker; the bridge goroutine callsprogram.Send(event.Payload)for each event. - Key types:
Broker[T](generics,sync.RWMutex-protected, non-blocking publish — slow subscribers are dropped),Event[T],Publisher[T](interface) - Dependencies: none (pure stdlib)
Backend (internal/backend)#
- Package:
github.com/charmbracelet/crush/internal/backend - Responsibility: Server-side service facade. Manages a
csync.Map[string, *Workspace]where each workspace wraps anapp.Appinstance. Handles workspace create/delete requests over the REST API and proxies all operations to the correctapp.App. - Key types:
Backend,Workspace(embeds*app.App) - Dependencies:
internal/app,internal/csync,internal/proto
TUI (internal/ui)#
- Package:
github.com/charmbracelet/crush/internal/ui/ - Responsibility: All terminal rendering and user interaction. The root
ui/modelBubbleTeaModelcomposes fine-grained sub-models (chat,dialog,diffview,completions,attachments, etc.). All backend calls go through theWorkspaceinterface received at construction. Events from the backend arrive astea.Msgvia the subscription goroutine. - Key types:
ui/model.Model(root BubbleTea model),ui/common.Common(shared state: Workspace, config, styles, renderer) - Dependencies:
charm.land/bubbletea/v2,charm.land/lipgloss/v2,charm.land/glamour,internal/workspace
Persistence (internal/db, internal/session, internal/message, internal/history)#
- Package:
github.com/charmbracelet/crush/internal/db+ domain subpackages - Responsibility: SQLite-backed persistence via WASM-embedded SQLite (
ncruces/go-sqlite3). Schema managed with goose migrations; queries generated by sqlc. Domain services (session.Service,message.Service,history.Service) own typed CRUD over the generated query layer. - Key types:
db.Queries(sqlc-generated), per-serviceServiceinterfaces - Dependencies:
ncruces/go-sqlite3,pressly/goose,sqlc-dev/sqlc(build-time)
Data flow#
Interactive (TUI) user prompt → agent response#
User types prompt in TUI chat pane
→ ui/chat sends tea.Cmd that calls ws.AgentRun(ctx, sessionID, prompt)
→ AppWorkspace.AgentRun() → app.App.AgentCoordinator.Run(ctx, sessionID, prompt)
→ coordinator.Run():
1. Calls coordinator.UpdateModels(ctx) — refreshes LLM clients
2. Calls currentAgent.Run(SessionAgentCall{...})
→ SessionAgent.Run() enters the fantasy agent loop:
a. Loads message history from session store
b. Sends request to LLM provider (streaming)
c. Receives response; persists assistant message via message.Service
→ message.Service publishes updated event on its own pubsub channel
d. If LLM calls a tool:
- tool.Execute() runs (bash, edit, write, grep, etc.)
- permission.Service may block and emit a PermissionRequest event
- result is added to message history
- loop repeats (agentic loop)
e. When LLM stops tool-calling, result is returned
→ Agent events (new messages, tool calls, permission requests) flow via:
pubsub.Broker[tea.Msg] → ws.Subscribe goroutine → program.Send(msg)
→ BubbleTea dispatches msg to ui/model.Update()
→ TUI re-renders with new contentNon-interactive path (crush run "prompt")#
cobra runCmd.RunE
→ MaybePrependStdin(prompt) — prepend piped stdin if present
→ setupWorkspace(cmd)
→ app.RunNonInteractive(ctx, os.Stdout, prompt, …)
→ resolveSession() — create or continue session
→ go AgentCoordinator.Run() — agent loop in goroutine
→ select loop: reads from messages.Subscribe() channel
and streams content[readBytes:] to stdout as it arrives
→ spinner on stderr for TTY progressInitialization / Bootstrap#
main() [main.go]
├── optional: start pprof HTTP server (CRUSH_PROFILE env)
└── cmd.Execute()
├── colorprofile-aware version template setup
└── fang.Execute(rootCmd, …) ← cobra + Ctrl-C signal handling
└── rootCmd.RunE:
├── setupWorkspace(cmd)
│ ├── config.Init(cwd, dataDir, debug) — load crush.json + env
│ ├── projects.Register(cwd, dataDir) — add to project registry
│ ├── db.Connect(ctx, dataDir) — open SQLite, run goose migrations
│ ├── crushlog.Setup(logFile, debug) — configure slog file handler
│ ├── app.New(ctx, conn, store)
│ │ ├── wire domain services (sessions, messages, history, perms, filetracker)
│ │ ├── lsp.NewManager(store)
│ │ ├── app.setupEvents() — start service event fanout goroutines
│ │ ├── go mcp.Initialize(ctx, …) — MCP server connections (background)
│ │ ├── go app.checkForUpdates(ctx) — version check (background)
│ │ └── app.InitCoderAgent(ctx)
│ │ └── agent.NewCoordinator(…)
│ │ ├── discoverSkills()
│ │ ├── buildAgent() → buildProvider() + buildTools()
│ │ └── readyWg.Go(buildSystemPrompt, buildTools) — async
│ └── workspace.NewAppWorkspace(app, store)
├── event.AppInitialized()
├── ui.New(common, sessionID, continueLast)
├── tea.NewProgram(model, …)
├── go ws.Subscribe(program) — event bridge goroutine
└── program.Run() — BubbleTea event loopDependency injection: Manual wiring throughout. No DI framework. App.New() constructs all services from a *sql.DB and *config.ConfigStore. The Coordinator receives service interfaces as constructor parameters. The Workspace interface is injected into the TUI via common.Common.
Async initialization: Two things are initialized concurrently after app.New() returns:
mcp.Initialize— connects to configured MCP servers (can take seconds)buildSystemPrompt+buildToolsviareadyWg— first agentRun()call blocks onreadyWg.Wait()
Configuration#
Configuration is loaded by config.Init() from multiple sources in order:
- Built-in defaults —
crush.jsonat repo root defines default values - Global config file —
~/.config/crush/config.json(or XDG config dir) - Project config file —
.crush/config.jsonin the working directory - Environment overrides —
config.VariableResolverevaluates$VARand${VAR}templates in API keys, base URLs, and other string fields - Flag overrides —
--debug,--yolo,--data-dir,--cwdapplied after load
The config struct is validated against a JSON Schema (schema.json) generated by task schema. The crush schema subcommand prints the current schema for editor integration.
No Viper. Configuration is loaded manually with encoding/json. The ConfigStore type holds a *Config (pure data) plus an Overrides struct for runtime-only overrides (e.g., SkipPermissionRequests for --yolo). Writes go through ConfigStore methods that re-serialize to disk and notify subscribers.
Provider API keys support template substitution ($ANTHROPIC_API_KEY, $(command), etc.) via the VariableResolver, allowing shell-style secret injection without storing secrets in the config file.
Key design decisions#
1. Workspace interface as the singular frontend↔backend seam#
Every frontend operation (TUI interaction, crush run, future IDE integration) calls the Workspace interface. The two implementations — AppWorkspace (in-process) and ClientWorkspace (HTTP over Unix socket) — are swapped transparently via CRUSH_CLIENT_SERVER. This single choice enables optional process isolation, IDE integrations, and multi-client access without touching TUI or CLI code.
2. Generic pubsub.Broker[T] for all in-process event routing#
Rather than scattered channels or callbacks, all domain events (session created, message updated, permission requested, MCP state changed, LSP diagnostics updated, skills loaded) are funnelled through a single Broker[tea.Msg]. The TUI subscribes once and receives everything as BubbleTea messages. The broker is non-blocking: slow consumers drop events rather than stalling producers — an explicit trade-off of correctness for liveness in a real-time UI.
3. charm.land/fantasy as the LLM provider abstraction#
Rather than vendor-specific SDKs in the agent loop, all LLM calls go through fantasy.Provider / fantasy.LanguageModel / fantasy.AgentTool. Provider construction is isolated to the coordinator.buildProvider() switch statement. This means adding a new LLM provider requires: adding a provider-specific buildXxxProvider() method and a case in the switch. The rest of the agent loop is provider-agnostic.
4. Tool registration driven by config allowlists#
Tools are not hard-coded into the agent. buildTools() constructs the full candidate set and then filters it against agent.AllowedTools from the config. MCP tools are dynamically registered at runtime. This makes the tool set configurable per agent (the config supports multiple named agents), and the design explicitly anticipates future multi-agent configurations (TODO comments confirm this).
5. SessionAgent / sub-agent spawning for agentic delegation#
The agent_tool and agentic_fetch tools each spawn a child SessionAgent via coordinator.runSubAgent(). The sub-agent gets its own session in the database (a child session with a structured ID encoding the parent message and tool call), runs its own agentic loop, and propagates its cost back to the parent session. This is a first-class multi-agent pattern: the main agent can delegate work to sub-agents whose progress is visible as nested sessions in the TUI.
6. Server auto-start with version pinning#
In client/server mode, ensureServer() auto-spawns crush server as a detached subprocess when no socket exists. Crucially, it also version-checks the running server against the current client binary and restarts the server if they differ. This makes the client/server split invisible to the user while still ensuring binary compatibility.