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) and internal/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 Workspace indirectly via AppWorkspace. Also handles graceful shutdown.
  • Key types: App struct — fields Sessions, 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.Provider instances for each configured LLM backend, builds the tool list (including MCP and LSP tools), assembles and wraps tools with hook interception, and drives SessionAgent.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 (wraps fantasy.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 a fantasy.LanguageModel, then passes it to SessionAgent.
  • 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 single Broker[tea.Msg]. The TUI subscribes to this broker; the bridge goroutine calls program.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 an app.App instance. Handles workspace create/delete requests over the REST API and proxies all operations to the correct app.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/model BubbleTea Model composes fine-grained sub-models (chat, dialog, diffview, completions, attachments, etc.). All backend calls go through the Workspace interface received at construction. Events from the backend arrive as tea.Msg via 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-service Service interfaces
  • 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 content

Non-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 progress

Initialization / 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 loop

Dependency 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 + buildTools via readyWg — first agent Run() call blocks on readyWg.Wait()

Configuration#

Configuration is loaded by config.Init() from multiple sources in order:

  1. Built-in defaultscrush.json at repo root defines default values
  2. Global config file~/.config/crush/config.json (or XDG config dir)
  3. Project config file.crush/config.json in the working directory
  4. Environment overridesconfig.VariableResolver evaluates $VAR and ${VAR} templates in API keys, base URLs, and other string fields
  5. Flag overrides--debug, --yolo, --data-dir, --cwd applied 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.