← 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
workspacesis 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 receiveworkspaceIdas a required parameter in CRUD operations… BothRoutaSystem(TypeScript) andAppState(Rust) propagate workspace context through their service layers.” This is not aspirational prose alone —acp_sessions(drizzle-sqlite/0000_natural_miracleman.sql:1-14) hasworkspace_id text NOT NULL, its owncwd,provider,role,mode_idcolumns per row;custom_mcp_servers(drizzle-sqlite/0005_add_custom_mcp_servers.sql:1-14) has a nullableworkspace_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-15shows the migration that splitrepo_path/branchoff theworkspacestable entirely into a dedicatedcodebasestable keyed byworkspace_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-273and ADR 0003 both flag this explicitly: “some paths still fall back todefaultwhen workspace scope is omitted… Treatdefaultas 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-15frames 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’sprocess.env.VECTOR_DBdesign. - 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) holdingMap<providerId, ProviderFactory>;create(id, config, autoInitialize)(lines 178-188) looks up the factory and calls it with aProviderCreateConfigthat explicitly carriesworkspaceId,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), callensure_mcp_for_providerto write a workspace+session-scoped MCP config (line 394-402;ensure_mcp_for_opencode/ensure_mcp_for_codex,mcp_setup.rs:90-118, 373-398each 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 theAcpManager’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 withwsId={workspace_id}andsid={session_id}as query params againstROUTA_SERVER_URL(defaulting tohttp://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 row —session_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, perAgentRoleinapi-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, perProfile__Anything-LLM.md) — there is no live process, no persistent connection, nothing to “swap” except which factory branch executes. Routa’sAcpManagerinstead holds an actualArc<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) andhttp://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 plusEventBusand MCP tool surfaces;AppState(crates/routa-core/src/state.rs:27-50) wires the Rust-side equivalent set plusAcpManager, 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) anddrizzle-sqlite/(SQLite) — eighteen-plus migrations each, largely mirrored (0001_workspace_centric.sqlin both,0010_add_acp_session_execution_binding.sql/0010_add_session_branch.sqlcolliding numbers indrizzle-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/apiand appends a configured backend base URL;getConfiguredBackendBaseUrl()(src/client/config/backend.ts:41-47) resolves from a?backend=query param, thenlocalStorage["routa.backendBaseUrl"], thenNEXT_PUBLIC_ROUTA_BACKEND_BASE_URL— anddesktopAwareFetch(path, options)(src/client/utils/diagnostics.ts:145-151) is the single call site meant to replace rawfetch('/api/...')everywhere, falling back tohttp://127.0.0.1:3210when 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 separateapi:test:nextjs/api:test:rustcontract-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
| Path | What’s there |
|---|---|
docs/ARCHITECTURE.md | Canonical topology doc — runtime boundaries, domain model, protocol stack, persistence-per-backend, transitional caveats ("default" workspace) |
docs/adr/0001-dual-backend-semantic-parity.md | Decision + CI enforcement for web/desktop contract parity |
docs/adr/0002-provider-normalization-via-acp.md | Decision to normalize all agent CLIs to ACP via per-provider adapters |
docs/adr/0003-workspace-first-scope.md | Decision making workspace the mandatory top-level scope for every domain entity |
api-contract.yaml | OpenAPI 3.1 shared contract; lists both backend server URLs literally |
crates/routa-core/src/state.rs | AppStateInner/AppState — the one process-wide Rust state container (stores, AcpManager, SkillRegistry, SandboxManager, EventBus) |
crates/routa-core/src/acp/mod.rs | AcpManager, ManagedProcess, AcpSessionRecord, create_session/load_session_with_options — the actual per-session process spawn path |
crates/routa-core/src/acp/mcp_setup.rs | ensure_mcp_for_provider, build_mcp_endpoint — per-workspace/per-session MCP config and coordination-endpoint URL construction |
crates/routa-server/src/api/*.rs | Axum 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.ts | TypeScript RoutaSystem — the Next.js-side equivalent service container |
src/core/acp/provider-registry.ts | ProviderRegistry 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.ts | resolveApiPath/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.md | Auto-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
AcpManageras 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.yamlplus 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-273lists 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.kaasis 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 RustLLMClientenum. - You want to see the Rust/TypeScript duplication fully resolved.
docs/ARCHITECTURE.mditself 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
| Axis | Routa | anything-llm | dive |
|---|---|---|---|
| Shells/surfaces | Tauri 2 desktop + Next.js web + VS Code extension, one api-contract.yaml | None in this checkout — Docker/Node web app only | Tauri and Electron (dual, same React frontend) |
| Backend language(s) | Rust/Axum (desktop/local) and TypeScript/Next.js (web) — two independent implementations of one contract | Node/Express, single implementation | Rust/TS shell + separate Python dive_httpd subprocess (submodule) |
| “Workspace” scope | Real schema-level scope for sessions, codebases, worktrees, tasks, notes, kanban, schedules (workspace_id NOT NULL FKs); "default" explicitly flagged as transition scaffolding, not the model | Prisma row parameterizing one shared Express process; no separate process or adapter per workspace | N/A — Dive has no workspace concept in this checkout, one MCP host process for the whole app |
| Adapter/backend swap unit | Per-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 row | Per-request LLM connector factory re-built from a DB row (getLLMProvider) — no live process, no cached adapter object | One 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 mechanism | Real 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 process | None — all tools/providers/conversations flow through the one dive_httpd process for every workspace-equivalent context |
| Protocol surface | REST, MCP, ACP, A2A, AG-UI, A2UI, SSE — six protocols, each with dedicated Axum router files | None (chat-completion REST only) | MCP only (stdio + SSE + OAP-hosted), inside the Python subprocess |
| Contract enforcement | api-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 fit | Reference 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 question | Reference for “config-row parameterization of one shared backend” — the negative result | Reference 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.yamland 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_sessionspawns a real child process per session, wired to awsId/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-wideAcpManager/AppStatesingleton, and a sharp contrast with AnythingLLM’s per-request config-row factory and Dive’s single shared subprocess for the whole app.