← Corpus / conversational-ui-and-native-shells / profile

routa

Path
profiles/Profile__Routa.md

Routa — Profile

A profile of Routa as it lives in this study (studies/conversational-ui-and-native-shells/routa/). LICENSE:1-3 confirms MIT, “Copyright (c) 2024-2025 Routa Community.” Read alongside Profile__Anything-LLM.md (shallow, config-row workspace switch over one shared backend) and Profile__Dive.md (thin shell, all logic proxied to one Python subprocess) — Routa is the study’s one candidate that genuinely instantiates a distinct backend process per unit of work, keyed by workspace, and the unit that gets swapped is not “which LLM API key” but “which coding-agent CLI/runtime, normalized through ACP, with its own MCP endpoint, cwd, and model.”

TL;DR

The repo’s own docs/ARCHITECTURE.md:23 states the intent directly:

The project is intentionally not “two separate products”. Web and desktop differ in deployment model and storage, but they are expected to preserve the same domain semantics, API shape, and agent-coordination behavior.

Mechanically: Routa ships three client surfaces against one contract — a Next.js web app (src/app/), a Tauri 2 desktop shell (apps/desktop/, tauri = { version = "2", ... } at apps/desktop/src-tauri/Cargo.toml:17), and a VS Code extension (apps/vscode/, src/routa-client.ts, src/server-manager.ts) — all validated against one api-contract.yaml at the repo root (api-contract.yaml:1-14: “Single source of truth for the Routa.js dual-backend API… Both the Next.js backend and the Rust backend MUST implement all endpoints defined here with compatible request/response shapes”). This is enforced, not aspirational: ADR 0001 (docs/adr/0001-dual-backend-semantic-parity.md:18) names CI jobs npm run api:test:nextjs vs npm run api:test:rust that run contract-parity tests against both backends.

The unit that actually gets swapped at runtime is a workspace-scoped ACP (Agent Client Protocol) session, not an LLM provider config row. AppStateInner (crates/routa-core/src/state.rs:27-50) is one process-wide struct — one AcpManager, one SkillRegistry, one SandboxManager, one set of stores — so at the state-container level Routa looks like AnythingLLM’s one-shared-Express-process pattern. But AcpManager (crates/routa-core/src/acp/mod.rs:155-164) is a registry of live child processes, not a config cache: sessions: HashMap<sessionId, AcpSessionRecord>, processes: HashMap<sessionId, ManagedProcess>, one broadcast channel and one message-history buffer per session. create_session/load_session_with_options (crates/routa-core/src/acp/mod.rs:327-352, 358-486) take workspace_id, cwd, provider, role, model, mcp_profile as real per-call arguments, resolve a provider preset (get_preset_by_id_with_registry), call mcp_setup::ensure_mcp_for_provider(provider_name, &cwd, &workspace_id, &session_id, ...) (line 394-402) to write a provider-specific MCP config scoped to that workspace+session pair, spawn the actual child process (process.load_session(...), line 443-445), and register it (register_managed_session(...), lines 462-476) with its own AgentProcessType::Acp(Arc<...>) handle and its own MCP teardown closure. Two workspaces really do end up with two independent OS processes, two independent cwd/worktree roots, two independent MCP endpoint URLs (build_mcp_endpoint, crates/routa-core/src/acp/mcp_setup.rs:29-41, which bakes wsId={workspace_id} and sid={session_id} into the MCP server’s URL query string), and — because provider is a free per-call argument — two potentially different coding-agent backends entirely (Claude Code SDK stream-json, OpenCode ACP-native, Codex, etc., per ADR 0002).

If you want one sentence: Routa’s “adapter swap” is real at the session/process layer — AcpManager::create_session spawns a genuinely distinct child process per session, wired to a workspace-and-session-scoped MCP endpoint and a provider-specific preset/adapter chosen per call — but the swap is not itself a “per-workspace persistent connection object”; a workspace can run zero, one, or many concurrently-different-provider sessions, and the thing holding all of them is one process-wide AcpManager singleton, same as AppStateInner holds one of everything else.

The workspace boundary — real schema, not a config-row convenience field

  • workspaces is the top-level coordination boundary, by explicit decision. ADR 0003 (docs/adr/0003-workspace-first-scope.md:16-21): “All domain entities are workspace-scoped… Store implementations receive workspaceId as a required parameter in CRUD operations… Both RoutaSystem (TypeScript) and AppState (Rust) propagate workspace context through their service layers.” This is not aspirational prose alone — acp_sessions (drizzle-sqlite/0000_natural_miracleman.sql:1-14) has workspace_id text NOT NULL, its own cwd, provider, role, mode_id columns per row; custom_mcp_servers (drizzle-sqlite/0005_add_custom_mcp_servers.sql:1-14) has a nullable workspace_id — meaning an MCP server config can be workspace-private or global, a real per-workspace override point at the schema level.
  • A workspace owns multiple codebases and worktrees, not one repo. docs/ARCHITECTURE.md:106-111: “A workspace can own multiple codebases… Worktrees are ephemeral or semi-persistent execution copies tied to a workspace and codebase.” drizzle-sqlite/0001_workspace_centric.sql:4-15 shows the migration that split repo_path/branch off the workspaces table entirely into a dedicated codebases table keyed by workspace_id, with a unique index on (workspace_id, repo_path) — proof this was a deliberate normalization away from “one workspace = one repo.”
  • The "default" workspace is named transition scaffolding, not the target model. docs/ARCHITECTURE.md:266-273 and ADR 0003 both flag this explicitly: “some paths still fall back to default when workspace scope is omitted… Treat default as transition scaffolding, not as the target domain model.” This is the kind of honest in-repo caveat AnythingLLM’s profile never got from its own maintainers — Routa’s own docs pre-empt the “is this really isolated” question.

The ACP provider layer — genuine per-session process spawn, not a shared HTTP client

  • ADR 0002 is the direct answer to this study’s central question, and it was decided, not assumed: docs/adr/0002-provider-normalization-via-acp.md:9-15 frames the choice as “let provider-specific protocol details leak through the system… or normalize everything behind a single protocol layer,” and picks normalization: Provider process or bridge → provider-specific output/notifications → adapter normalization → unified session updates → persistence, traces, UI streaming. Concretely: “Per-session model configuration replaces global env-var model selection (issue #33)” — i.e. Routa’s own history includes moving away from a shared-global-config pattern toward per-session configuration, the opposite direction of AnythingLLM’s process.env.VECTOR_DB design.
  • The registry is a factory keyed by provider ID, resolved per call, but the object it returns is a live process, not a request-scoped struct. TypeScript side: ProviderRegistry (src/core/acp/provider-registry.ts:135-203) is a singleton (getInstance(), line 144-149) holding Map<providerId, ProviderFactory>; create(id, config, autoInitialize) (lines 178-188) looks up the factory and calls it with a ProviderCreateConfig that explicitly carries workspaceId, workspacePath, sessionId, model, extraArgs, extraEnv (lines 109-128) — the workspace identity is threaded into the constructor call, not bolted on after. Rust side: AcpManager::create_session_with_options/load_session_with_options (crates/routa-core/src/acp/mod.rs:358-486) do the actual OS-level work — resolve the CLI preset (get_preset_by_id_with_registry(provider_name), line 392), build provider-specific extra args including -m <model> (lines 408-423), call ensure_mcp_for_provider to write a workspace+session-scoped MCP config (line 394-402; ensure_mcp_for_opencode/ensure_mcp_for_codex, mcp_setup.rs:90-118, 373-398 each write to that provider’s own config location, e.g. ~/.config/opencode/opencode.json, merged rather than clobbered), then spawns the child (process.load_session(&resolved_provider_session_id, &cwd, &acp_mcp_servers), line 443-445) and registers it in the AcpManager’s maps (register_managed_session, lines 462-476).
  • The MCP endpoint each spawned agent talks back to is itself workspace-and-session-scoped. build_mcp_endpoint (crates/routa-core/src/acp/mcp_setup.rs:29-46) constructs a URL with wsId={workspace_id} and sid={session_id} as query params against ROUTA_SERVER_URL (defaulting to http://127.0.0.1:3210) — so the coordination MCP server (routa-coordination, build_acp_http_mcp_servers, lines 76-88) that every provider gets wired to is disambiguated per session at the URL level, not by a shared implicit “current workspace” global.
  • AcpSessionRecord (crates/routa-core/src/acp/mod.rs:75-95) is the session’s own rowsession_id, cwd, workspace_id, provider, role, mode_id, model, parent_session_id (for CRAFTER child sessions spawned by a parent agent), specialist_id. A parent-child session tree (kanban automation spawning a CRAFTER from a ROUTA role, per AgentRole in api-contract.yaml:22-24) is expressible directly in this schema, each node independently provider/model-scoped.
  • Contrast with AnythingLLM’s resolveProviderConnector: AnythingLLM rebuilds a stateless HTTP client from a DB row read fresh every chat call (server/utils/helpers/index.js:626-644, per Profile__Anything-LLM.md) — there is no live process, no persistent connection, nothing to “swap” except which factory branch executes. Routa’s AcpManager instead holds an actual Arc<ManagedProcess> per session (crates/routa-core/src/acp/mod.rs:132-146: process, acp_session_id, preset_id, trace_writer, cwd, mcp_cleanup) for the session’s entire lifetime — closer to Dive’s “spawn a subprocess and talk to it,” except Routa spawns per-session (many possible per workspace, each independently provider-selectable) rather than Dive’s one subprocess per app launch shared by the whole UI.

Protocol breadth — ACP is the execution transport; MCP/A2A/AG-UI/A2UI are first-class, not bolted on

docs/ARCHITECTURE.md:181-193 tabulates six protocol surfaces as equally canonical: REST (/api/*), MCP (/api/mcp, /api/mcp/tools), ACP (/api/acp + runtime/registry/Docker routes), A2A (/api/a2a/*), AG-UI (/api/ag-ui), A2UI (/api/a2ui/*), SSE (cross-cutting). This is not documentation aspiration — the Rust router has a dedicated file per protocol: crates/routa-server/src/api/a2a.rs (483 lines — the largest of the protocol-adapter files, full agent-to-agent interop surface), ag_ui.rs (26 lines, a thin AG-UI stream adapter), a2ui.rs (48 lines, dashboard-oriented UI protocol), mcp_routes.rs (251 lines) plus a mcp_routes/ subdirectory and separate mcp_server_mgmt.rs/mcp_servers.rs/mcp_tools.rs files, and acp_routes.rs/acp_registry.rs/acp_docker.rs for the ACP execution-transport family — crates/routa-server/src/api/mod.rs wires all of them into one Axum router (per docs/ARCHITECTURE.md:251-262: “The Axum router… shows the breadth of the desktop/server backend… This breadth is intentional: the desktop backend is not a thin transport shim. It is a full local coordination runtime.”). Per-workspace scoping for MCP specifically is real at the schema level (custom_mcp_servers.workspace_id, nullable — global or workspace-private, drizzle-sqlite/0005_add_custom_mcp_servers.sql:11) and real at the runtime level (the wsId/sid-scoped coordination endpoint above); A2A/AG-UI/A2UI are cross-cutting protocol adapters rather than workspace-partitioned resources in the code read here.

The dual-backend contract in practice — one YAML, two implementations, a shared client-side resolver

  • api-contract.yaml (OpenAPI 3.1, api-contract.yaml:1-14) lists both backends as literal servers: http://localhost:3000 (Next.js dev) and http://localhost:3210 (Rust desktop) — the same document, not two documents kept in sync by convention.
  • Both TypeScript and Rust have their own “system factory” wiring the same domain vocabulary, per docs/ARCHITECTURE.md:156-179: RoutaSystem (src/core/routa-system.ts) wires stores for agents/conversations/tasks/notes/workspaces/codebases/worktrees/schedules/kanban/background-tasks/workflow-runs/artifacts plus EventBus and MCP tool surfaces; AppState (crates/routa-core/src/state.rs:27-50) wires the Rust-side equivalent set plus AcpManager, binary/runtime/warmup managers, SkillRegistry, SandboxManager, Docker detection.
  • Storage genuinely differs by backend, deliberately (docs/ARCHITECTURE.md:54-57, 241-244): web prefers Postgres (DATABASE_URL) with SQLite/in-memory fallbacks; desktop is SQLite-only plus filesystem state (JSONL traces, worktrees, agent binaries). Two parallel Drizzle migration trees exist on disk for this: drizzle/ (Postgres) and drizzle-sqlite/ (SQLite) — eighteen-plus migrations each, largely mirrored (0001_workspace_centric.sql in both, 0010_add_acp_session_execution_binding.sql/0010_add_session_branch.sql colliding numbers in drizzle-sqlite/ showing this is actively-maintained, occasionally-messy parallel evolution, not a generated mirror).
  • The frontend resolves which backend to call via one small shared module, per the repo’s own CLAUDE.md (routa’s in-repo agent contract, not this study’s): resolveApiPath (src/client/config/backend.ts) prefixes /api and appends a configured backend base URL; getConfiguredBackendBaseUrl() (src/client/config/backend.ts:41-47) resolves from a ?backend= query param, then localStorage["routa.backendBaseUrl"], then NEXT_PUBLIC_ROUTA_BACKEND_BASE_URL — and desktopAwareFetch(path, options) (src/client/utils/diagnostics.ts:145-151) is the single call site meant to replace raw fetch('/api/...') everywhere, falling back to http://127.0.0.1:3210 when running inside the Tauri static export. This is a genuinely shared client resolver, not two independently hand-maintained HTTP clients — but it is a runtime base-URL switch, not a generated-SDK/typed-client guarantee that the two backends’ response shapes actually match (that guarantee comes from the separate api:test:nextjs/api:test:rust contract-parity CI jobs instead).
  • A third client, the VS Code extension (apps/vscode/src/routa-client.ts, server-manager.ts, workspace-sync.ts), talks to the same contract from inside the editor — evidence this is a genuinely multi-surface product, not just “web plus one desktop wrapper.”

What’s inside this submodule

PathWhat’s there
docs/ARCHITECTURE.mdCanonical topology doc — runtime boundaries, domain model, protocol stack, persistence-per-backend, transitional caveats ("default" workspace)
docs/adr/0001-dual-backend-semantic-parity.mdDecision + CI enforcement for web/desktop contract parity
docs/adr/0002-provider-normalization-via-acp.mdDecision to normalize all agent CLIs to ACP via per-provider adapters
docs/adr/0003-workspace-first-scope.mdDecision making workspace the mandatory top-level scope for every domain entity
api-contract.yamlOpenAPI 3.1 shared contract; lists both backend server URLs literally
crates/routa-core/src/state.rsAppStateInner/AppState — the one process-wide Rust state container (stores, AcpManager, SkillRegistry, SandboxManager, EventBus)
crates/routa-core/src/acp/mod.rsAcpManager, ManagedProcess, AcpSessionRecord, create_session/load_session_with_options — the actual per-session process spawn path
crates/routa-core/src/acp/mcp_setup.rsensure_mcp_for_provider, build_mcp_endpoint — per-workspace/per-session MCP config and coordination-endpoint URL construction
crates/routa-server/src/api/*.rsAxum routers, one file per protocol/domain surface (a2a.rs, ag_ui.rs, a2ui.rs, mcp_routes.rs, acp_routes.rs, workspaces.rs, kanban.rs, worktrees.rs, etc.)
src/core/routa-system.tsTypeScript RoutaSystem — the Next.js-side equivalent service container
src/core/acp/provider-registry.tsProviderRegistry singleton, ProviderCreateConfig (carries workspaceId/workspacePath/sessionId), model-tier resolution
src/core/acp/claude-code-sdk-adapter.ts, src/core/acp/provider-adapter/Per-provider adapters translating raw provider output into normalized ACP-like session updates
src/client/config/backend.ts, src/client/utils/diagnostics.tsresolveApiPath/desktopAwareFetch — the shared client-side backend-URL resolver used by both web and Tauri-static builds
apps/desktop/src-tauri/Tauri 2 shell (tauri = "2"), src/lib.rs/main.rs, tray.rs, pty.rs, embeds/launches the Axum server
apps/vscode/Third client surface — VS Code extension (routa-client.ts, server-manager.ts, workspace-sync.ts) against the same contract
drizzle/ vs drizzle-sqlite/Parallel Postgres and SQLite migration trees — the concrete evidence of “storage differs, domain semantics don’t”
docs/product-specs/FEATURE_TREE.mdAuto-generated endpoint/route inventory (referenced, not duplicated, by ARCHITECTURE.md)

If you read three files: docs/ARCHITECTURE.md (the whole intent, invariants, and honest caveats live here), crates/routa-core/src/acp/mod.rs (the actual per-session process-spawn mechanism — AcpManager, ManagedProcess, create_session_with_options), and crates/routa-core/src/acp/mcp_setup.rs (proof the MCP coordination endpoint is scoped by workspace+session, not global).

Mental model for using it well

  • Distinguish “workspace” (a coordination scope) from “session” (the thing that actually gets a distinct process/adapter). A workspace groups codebases, worktrees, tasks, notes, kanban boards, schedules — it is the addressing boundary. The genuinely swappable unit — provider, model, MCP config, child process — lives at the session layer, and a workspace can host many concurrently-different-provider sessions, not one fixed adapter per workspace.
  • Read AcpManager as a live-process registry, not a config cache. Every session it tracks corresponds to a real spawned child (ManagedProcess.process) plus its own broadcast channel, trace writer, and MCP cleanup closure — closer in kind to Dive’s subprocess-supervision pattern than to Kaas’s or AnythingLLM’s stateless request-scoped client construction, except Routa does this per-session (many per app run) rather than per-app-launch (one, in Dive).
  • Expect the dual-backend claim to be contract-level, not code-shared-level. Next.js and Rust are two independent implementations kept aligned by api-contract.yaml plus CI parity tests (api:test:nextjs/api:test:rust), not by a shared library — same posture as Dive’s shell duplication, but here it’s the entire backend duplicated per language, with the contract (not the code) as the shared artifact.
  • Treat "default" workspace fallbacks and TypeScript/Rust asymmetry as named, tracked debt, not hidden gaps — docs/ARCHITECTURE.md:264-273 lists exactly which invariants aren’t fully met yet (“not every persistence-backed implementation is fully symmetric yet across TypeScript and Rust… some workflow-run persistence remains in-memory even when other stores are persistent”).
  • Look to ADRs first for “why,” not source archaeology. docs/adr/ is a maintained decision log (six ADRs at this pin) that pre-answers most “is this deliberate or accidental” questions this study would otherwise have to infer from code alone.

When NOT to reach for this

  • You want a small, readable reference implementation. Routa is the largest and most heavily-tooled repo in this study by a wide margin (docs/fitness/, entrix quality gates, .agents/skills/, three client surfaces, two migration trees, six protocol adapters) — it is a full product, not a minimal pattern demonstration. kaas is the right read for a compact single-shell reference.
  • You want per-workspace LLM chat provider switching in the AnythingLLM sense (temperature, system prompt, chat model for a document-QA assistant). That’s not really Routa’s axis — Routa’s adapter surface is “which coding-agent CLI executes this session” (Claude Code SDK, OpenCode, Codex), normalized through ACP; it is not a consumer chat-app provider picker.
  • You need one language, one process, no subprocess boundary. Every ACP session is a spawned child process communicating over the ACP wire protocol (ManagedProcess.process, crates/routa-core/src/acp/mod.rs:133) — closer to Dive’s subprocess model than Kaas’s in-process Rust LLMClient enum.
  • You want to see the Rust/TypeScript duplication fully resolved. docs/ARCHITECTURE.md itself names the asymmetries still open ("default" fallbacks, incomplete cross-backend persistence symmetry, some in-memory-only workflow-run state) — read this as an honest in-flight system, not a finished dual-backend demonstration.

How this compares to the rest of the study

AxisRoutaanything-llmdive
Shells/surfacesTauri 2 desktop + Next.js web + VS Code extension, one api-contract.yamlNone in this checkout — Docker/Node web app onlyTauri and Electron (dual, same React frontend)
Backend language(s)Rust/Axum (desktop/local) and TypeScript/Next.js (web) — two independent implementations of one contractNode/Express, single implementationRust/TS shell + separate Python dive_httpd subprocess (submodule)
“Workspace” scopeReal schema-level scope for sessions, codebases, worktrees, tasks, notes, kanban, schedules (workspace_id NOT NULL FKs); "default" explicitly flagged as transition scaffolding, not the modelPrisma row parameterizing one shared Express process; no separate process or adapter per workspaceN/A — Dive has no workspace concept in this checkout, one MCP host process for the whole app
Adapter/backend swap unitPer-session ACP process spawn (AcpManager::create_session) — provider/model/MCP-config chosen per call, workspace threaded through as a real argument, not read from a cached rowPer-request LLM connector factory re-built from a DB row (getLLMProvider) — no live process, no cached adapter objectOne shared Python subprocess (dive_httpd) for the whole app — no per-context swap at all, only per-tool enable/disable within that one host
Isolation mechanismReal OS child process per session (ManagedProcess), workspace+session-scoped MCP endpoint URL (wsId/sid query params)Vector-DB namespace keyed by workspace.slug inside one shared store; LLM provider is a fresh factory call, not an isolated processNone — all tools/providers/conversations flow through the one dive_httpd process for every workspace-equivalent context
Protocol surfaceREST, MCP, ACP, A2A, AG-UI, A2UI, SSE — six protocols, each with dedicated Axum router filesNone (chat-completion REST only)MCP only (stdio + SSE + OAP-hosted), inside the Python subprocess
Contract enforcementapi-contract.yaml + CI parity tests (api:test:nextjs/api:test:rust)N/A (single backend)No shared schema between shell and Python host — plain fetch()
Best fitReference for “genuine per-session/per-workspace adapter and process isolation across a protocol-rich, dual-backend product” — the strongest positive result in this study for that questionReference for “config-row parameterization of one shared backend” — the negative resultReference for “shell as thin process supervisor around one shared backend” — a different negative result (no per-context swap at all)

Routa’s load-bearing contribution to this study is the positive result the other two profiles were measured against: a workspace-scoped domain model backed by a per-session live-process registry (AcpManager), where the provider/model/MCP-config/child-process are all resolved from real per-call arguments (workspace_id, session_id, provider, model) rather than read from a single cached row or funneled through one shared subprocess — while being explicit, in its own ADRs, about exactly which cross-backend and default-workspace invariants are still incomplete.

One-line summary

Routa is a workspace-first, dual-backend (Next.js + Rust/Axum) multi-agent coordination platform — one api-contract.yaml and CI-enforced parity tests govern three client surfaces (Tauri desktop, web, VS Code) — where the genuinely swappable unit is a workspace-and-session-scoped ACP process: AcpManager::create_session spawns a real child process per session, wired to a wsId/sid-scoped MCP coordination endpoint and a provider preset chosen per call (Claude Code SDK, OpenCode, Codex), so two sessions in two workspaces really can run on two different coding-agent backends with independent processes, cwd, and MCP config — a genuine per-session adapter/process swap, layered under one process-wide AcpManager/AppState singleton, and a sharp contrast with AnythingLLM’s per-request config-row factory and Dive’s single shared subprocess for the whole app.