Router Backends & Embedded Services (ADR)
Router Backends & Embedded Services — architecture contract (ADR)
Section titled “Router Backends & Embedded Services — architecture contract (ADR)”Status: Accepted · Context: #5670, #5603 · Contract:
domain/routing/routerBackends.ts(typed registry — code lands with #5868)
This ADR pins down how ts (native), bifrost, cliproxy, 9router, and
VibeProxy-compatible engines relate to each other, so contributors stop
conflating two things that are architecturally distinct. It documents the typed
registry introduced by the router-backend-registry work as the single source of
truth for that model.
The core distinction — two orthogonal axes
Section titled “The core distinction — two orthogonal axes”An engine’s role is described by two independent axes, encoded together in the
registry’s RouterBackendDefinition:
- Lifecycle (
RouterBackendLifecycle) — how the engine runs:in-process— runs inside the OmniRoute Node process (the native TS pipeline).supervised— a local child process OmniRoute installs/starts/stops/health-checks viaServiceSupervisor, then consumes as a provider connection.external— an HTTP endpoint OmniRoute dispatches to but does not manage (configured by an env base URL).disabled— registered but not selectable.
- Selection axis (relay routing backend) — whether the relay dispatches to it:
RelayRoutingBackend = "ts" | "bifrost" | "auto"insrc/app/api/v1/relay/chat/completions/routingBackend.ts.
The mistake to avoid: treating “embedded service” and “routing backend” as one
list. They are not. A supervised engine (9router/cliproxy) is a provider
connection consumed by the native pipeline, not an alternate relay dispatch
backend. bifrost is the reverse — a relay dispatch backend that (historically)
was external-only.
The registry — single source of truth
Section titled “The registry — single source of truth”The domain/routing/routerBackends.ts contract (code lands with
#5868) declares every engine once, with its
lifecycle, capabilities, service identity, default port, health config, and
telemetry support. Consumers look engines up via getRouterBackend(id),
listRouterBackends(), and listRouterBackendsByCapability(cap) instead of
special-casing each sidecar.
| Backend | Lifecycle | Service (axis A) | Relay backend (axis B) | Health | Default port |
|---|---|---|---|---|---|
ts |
in-process |
— | ts (native) |
— | — |
bifrost |
external¹ |
—¹ | bifrost / auto |
/health |
— |
cliproxy |
supervised |
cliproxy |
— (provider) | /v1/models |
8317 |
9router |
supervised |
9router |
— (provider) | /api/health |
20130 |
vibeproxy |
external |
— | — (provider adapter) | /v1/models |
— |
¹ Bifrost’s promotion to a supervised embedded service (installable/startable
from /api/services/bifrost/) is tracked in
#5817; until it merges,
Bifrost is external-only (reachable solely via BIFROST_BASE_URL).
capabilities (chat, responses, streaming, tools, vision,
oauth-backed, dashboard-embed, model-sync, native-hot-path) let callers
filter by what an engine can actually do rather than hard-coding per-id branches.
Axis A — embedded services (supervised process side)
Section titled “Axis A — embedded services (supervised process side)”- Registry of supervised processes:
src/lib/services/bootstrap.tsSERVICES[](today:9router,cliproxy). - Lifecycle owner:
src/lib/services/ServiceSupervisor.ts—start()spawns the child, gates onwaitForHealthy(), taps stdout/stderr into a ring buffer;stop()SIGTERM→SIGKILL; all serialized under a lock. - State union (
src/lib/services/types.ts):not_installed | stopped | starting | running | stopping | error, plus an orthogonalHealthState = healthy | unhealthy | unknown. - Why a separate process (not an in-proc SDK)? Process isolation is what makes
install/start/stop/health/logs independently controllable per sidecar and lets the
loopback spawn-guard apply. Modeling an in-proc adapter is future work — the
native-hot-pathcapability flag is where that would be expressed.
Lifecycle route contract (/api/services/<tool>/…)
Section titled “Lifecycle route contract (/api/services/<tool>/…)”Status codes are state/verb/path-specific by design — this is the contract, not inconsistency:
| Call | Condition | Status |
|---|---|---|
POST .../start |
service not_installed |
409 (precondition) |
POST .../stop |
already stopped | 200 (idempotent no-op) |
GET .../status |
OK | 200 (live ?? row ?? "unknown") |
POST .../start |
spawn failure | 503 (transient) |
GET .../status, .../stop |
uncaught error | 500 |
GET /api/services/<x>/logs |
unknown tool <x> |
404 Service '<x>' not found |
GET .../status?reveal=key |
missing X-Reveal-Confirm: yes |
403 (9router only) |
any /api/services/* |
caller not loopback/private-LAN | 403 LOCAL_ONLY |
All error bodies are shaped by createErrorResponse() →
{ error: { message, type }, requestId }, where type is derived from the status
(500→server_error, 404→not_found, 409→conflict, else invalid_request) and is
the machine-actionable discriminator. Messages are pre-sanitized
(sanitizeErrorMessage(), Hard Rule #12).
The loopback guard is the most common source of a 403: /api/services/ is in
LOCAL_ONLY_API_PREFIXES (src/server/authz/routeGuard.ts) and
src/server/authz/policies/management.ts rejects any non-loopback / non-private-LAN
caller before auth, because these routes spawn child processes (Hard Rules 15
and 17). Reaching them through a public tunnel is 403 by design.
Axis B — relay routing backend (dispatch side)
Section titled “Axis B — relay routing backend (dispatch side)”Only the relay proxy path /api/v1/relay/chat/completions selects a dispatch
backend; the main /api/v1/chat/completions surface never consults
routingBackend.ts.
- Selection (
resolveRelayRoutingBackend): a single global env toggle —OMNIROUTE_RELAY_BACKEND/RELAY_ROUTING_BACKEND∈ {ts,bifrost,auto}. If unset,autowhen Bifrost is configured+enabled, elsets. - Behavior:
bifrost(forced): Bifrost failure → hard502, no fallback.auto: try Bifrost, on failure/cooldown silently fall through to native.ts/ post-fallback: the nativeopen-ssetranslator/executor pipeline.
- Cooldown: per-
baseUrlfailure cooldown inbifrostCooldown.ts.
Selection is all-or-nothing at the relay level today — there is no per-provider
or per-request engine swap on release/v3.8.43. The per-request gate is being added
by the sidecar-manifest work
(#5869 manifest +
#5870 shouldTryBifrostForRequest),
which lets auto route only manifest-eligible providers through Bifrost.
Dashboard integration
Section titled “Dashboard integration”The services dashboard polls GET /api/services/<tool>/status every 5s via
src/app/(dashboard)/dashboard/providers/services/hooks/useServiceStatus.ts,
returning { tool, state, pid, port, health, installedVersion, latestVersion, updateAvailable, autoStart, … }. There is no shared availability-context provider —
each component calls the hook per tool. On !res.ok the hook currently surfaces a
bare HTTP <status>; mapping the error.type field to a human explanation is a
tracked UX improvement, not a contract change.
Consequences
Section titled “Consequences”- New engines register once in
ROUTER_BACKENDS; consumers gain them via capability queries without new per-id branches. - “Is this a service or a routing backend?” is answered by the
lifecyclefield, not by which list an id happens to appear in. - The Bifrost supervision (#5817) and native hot-path migration (#5670) build on this shared contract instead of special-casing each sidecar.
HagiCode
HagiCode is an agentic coding workspace. Rich, convenient UI interactions make planning, agent execution, and project work approachable and easy to navigate, creating an engaging development experience.
Explore an engaging development experience through rich, convenient UI interactions.
- SmartOpenSpec workflows turn intent into an executable path, from proposal to design, tasks, and a shipped change.
- EfficientMulti-agent execution keeps research, implementation, and review moving in parallel, across several repositories and the Agent CLIs you already use.
- InterestingRich, convenient UI interactions make planning, agent execution, and project work approachable and easy to navigate.
Download for Windows
Share HagiCode
Turn ideas into shipped software.
HagiCode is an agentic coding workspace. OpenSpec workflows plan the change, multi-agent execution carries it out, and Hero Scenario makes the long run visible, so ideas become shipped software.
- Smart
- OpenSpec workflows turn intent into a path from proposal to shipped change.
- Efficient
- Multi-agent execution runs research, build, and review in parallel.
- Fun
- Rich UI interactions make planning, agent work, and projects easier to follow.
© 2026 HagiCode | Powered By hagilight-starlight@0.21.0 | Branch: main | Commit: de60bcd | Built:

