For most of Timothy’s life, a route was its name. Seven names were hardcoded and compared by string literal in two services: default, summarize, embedding, vision, research, local, coding. The gateway’s requiredCapability did if route == "embedding". The brain had package-level constants defaultRoute = "default" and visionRoute = "vision". The bootstrap map keyed off literal names. Rename a row in the database and Timothy quietly stopped finding it, because the name was the identity. Part 11 made providers rows of data; this part finishes the job for routes.
Why a name list does not generalize
The problem is that a name is doing two jobs at once. It is a label the user reads, and it is a contract the code depends on. Those two jobs want different things. The label should be freely renamable (call the chat route fast, call the summarize route cheap-draft, who cares). The contract has to be stable, because the brain has to ask “which route do I send embeddings through?” and get a real answer even if the route was renamed an hour ago.
When the name is the contract, you get special cases. The gateway’s old requiredCapability was one if statement matching the literal string embedding. The brain’s chat path had two constants for the two routes it pinned to. The bootstrap map in internal/gateway/router/bootstrap.go was a literal map[string]string keyed on default, summarize, embedding, vision. Every one of those is a place where the code knows a name instead of asking a question.
The decision: two new columns replace name-as-identity.
capability declares what a route can serve: chat, embeddings, or vision. It replaces the route == "embedding" special case. The gateway’s requiredCapability becomes a method on *Snapshot that reads the route’s declared capability:
// internal/gateway/router/router.go
func (s *Snapshot) requiredCapability(route string) provider.Capability {
switch s.capability[route] {
case "embeddings":
return provider.CapEmbeddings
case "vision":
return provider.CapVision
default:
return provider.CapChat
}
}
role marks a route as serving one of the four functions Timothy requires to work at all: default, embedding, vision, summarize. It is nullable, so a plain user-owned route just has no role. A partial unique index enforces that at most one route holds any given role:
-- migrations/0036_dynamic_routes.sql
CREATE UNIQUE INDEX IF NOT EXISTS routes_role_unique_idx
ON routes (role) WHERE role IS NOT NULL;
The new mental model: only four roles are required to bootstrap, everything else is user-owned, and code resolves “the route serving role X” through Snapshot.RouteForRole(role) instead of comparing names.
Why not the alternatives
Three rejected options are worth naming, because each is the obvious next thought.
A rename endpoint. Deliberately not shipped. The name is now a free display label; if you want a different name, create a new route under the new name and reassign the role. The README correction notes plainly that no rename endpoint exists. Adding one would be a third write path for a cosmetic change, and it would tempt people to rename the system-role routes (the one rename that does not matter, since role resolution ignores the name anyway).
Infer capability from the name. The route == "embedding" special case is exactly this mistake, and it is what the capability column replaces. A route called embeddings-cheap would have been misrouted as chat under the old code. Declaring capability explicitly means the name can be anything and the behavior is still correct.
A brain-side routes listing, polled for the vision check. An older comment in the chat path argued against adding a routes-listing call “just for this check.” This part adds role resolution, but it reuses the existing gwclient memoization pattern rather than a new poller. The same 30-second TTL, the same single Client mutex, the same network budget. No new polling machinery.
The migration deletes seed rows, honestly
Migration 0036_dynamic_routes.sql does something no earlier Timothy migration has done: it deletes user-creatable rows. Three seeded route names, research, local, coding, are dropped from a fresh install:
-- migrations/0036_dynamic_routes.sql
DELETE FROM routes
WHERE name IN ('research', 'local', 'coding')
AND role IS NULL AND chain = '[]' AND enabled = false;
The guard is the three-way AND: a row is deleted only if it has no role, an empty chain, and is disabled. That is safe because no create UI existed before this migration, so no real user chain can exist under those names on any install that has not manually configured them. An upgrader who already wired a chain onto one of those rows keeps it: the chain = '[]' condition fails and the row survives as a plain user-owned route.
One deliberate exception: local survives in the brain’s chat package. The classifyRoute = "local" constant stays, because classification still pins to the literal local name for the sensitive-session routing floor from Part 18. The four system roles have no hardcoded names anymore, but one user-owned route does. That is a real cost, stated plainly below.
How role resolution works without a new poller
The brain needs to answer “which route serves the default role?” at runtime, on every chat turn, every compaction, every mission fire. It asks the gateway through a new read endpoint:
// internal/gateway/api/api.go
// GET /v1/routes/roles reports which route currently serves each of
// the 4 roles Timothy requires to work.
func (a *API) handleRoleRoutes(w http.ResponseWriter, r *http.Request) {
snap := a.store.Snapshot()
// ... returns {"roles": {"default": "...", "embedding": "...", ...}}
}
The brain’s gateway client wraps that endpoint with the same memoization ModelWindows already used:
// internal/brain/gwclient/gwclient.go
func (c *Client) RouteForRole(ctx context.Context, role string) (string, bool, error) {
// ... cache check under c.mu, same 30s TTL as ModelWindows ...
c.roles, c.rolesExp = out.Roles, time.Now().Add(windowsTTL)
}
On failure or an unbound role it returns "". Callers must never hard-fail a turn over this; an empty route falls through to the gateway’s own no_route error, exactly as an unconfigured route always has.
The UI caught up
The Routing tab in web/src/components/settings/RoutesList.tsx was rewritten. A “System roles” card shows four dropdowns, one per role, each listing every route so the user can rebind. A create-row at the bottom makes a new route (name plus capability). Every route card gets a delete button, disabled when the route holds a system role, with a tooltip saying so. The old list was read-only cards; the new one is a full control surface.
The vision safety net in the gateway’s stream handler got one indirection more correct. It used to check req.Route == "vision". Now it keys on whether the message needs vision at all, and resolves the default role through snap.RouteForRole("default") to fall back to:
// internal/gateway/api/api.go
needsVision := len(requiredVisionCapability(req.Messages)) > 0
if needsVision && len(nre.Skipped) == 0 {
if defaultRoute, ok := snap.RouteForRole("default"); ok {
req.Route = defaultRoute
// ... retry resolve on the default role's route
}
}
More correct, one more indirection, and no longer name-coupled.
Shipped, and wrong for a few hours
The feature went out as a89af0d. It looked complete: the gateway had the new endpoints, the UI called them, the migration added the columns. It was not reachable through the public API.
Here is the mechanism, because it is the core of how the bug shipped.
The gateway’s admin API lives under /internal/admin/... and is compose-internal only. The gateway image is distroless and publishes no host port. The brain is the only public service. So the brain runs a bearer-authed reverse proxy to the gateway’s admin surface, and that proxy is gated by an exhaustive allowlist of method-and-path patterns:
// internal/brain/api/admin.go
// adminRoutePatterns is the EXHAUSTIVE admin surface brain exposes;
// everything else on the gateway's internal API stays unreachable
// from outside. Tests pin this scope like the memory proxy's.
var adminRoutePatterns = []string{
"GET /v1/admin/usage/{rest...}",
// ... providers ...
"GET /v1/admin/routes",
// (the three new patterns were missing here)
"PATCH /v1/admin/routes/{name}",
// ...
}
The proxy rewrites the path prefix on the way through, from the external /v1/admin/... to the gateway-internal /internal/admin/...:
// cmd/brain/main.go
r.Out.URL.Path = "/internal/admin/" + strings.TrimPrefix(r.In.URL.Path, "/v1/admin/")
The allowlist is exhaustive on purpose. Anything not listed never reaches the gateway; the proxy simply has no handler for it. The comment above the list is load-bearing: “everything else on the gateway’s internal API stays unreachable from outside.”
The feature commit added three endpoints to the gateway. They got handlers, routes, backing logic:
// internal/gateway/api/admin.go
srv.Handle("POST /internal/admin/routes", http.HandlerFunc(h.createRoute))
srv.Handle("DELETE /internal/admin/routes/{name}", http.HandlerFunc(h.deleteRoute))
srv.Handle("PUT /internal/admin/routes/{name}/role", http.HandlerFunc(h.setRouteRole))
And the three matching patterns were forgotten in the brain’s allowlist. The gateway code was correct. The UI called the endpoints. The requests hit the brain’s proxy boundary and were turned away, because no pattern matched POST /v1/admin/routes, DELETE /v1/admin/routes/{name}, or PUT /v1/admin/routes/{name}/role. Create a route, delete a route, reassign a role: all silently broken at the proxy. The UI showed an error; the gateway never saw the request.
There is no compile-time link between the two halves. The gateway registers handlers in internal/gateway/api/admin.go. The brain enumerates allowed patterns in internal/brain/api/admin.go. Two files, two packages, no type or test that forces them to move together. Adding a gateway admin endpoint is necessary and not sufficient. You must also add the brain-side pattern, or the endpoint exists but is unreachable from outside.
The fix was three lines:
// internal/brain/api/admin.go (commit 42ba8da)
"GET /v1/admin/routes",
"POST /v1/admin/routes", // added
"PATCH /v1/admin/routes/{name}",
"DELETE /v1/admin/routes/{name}", // added
"PUT /v1/admin/routes/{name}/role", // added
A second, independent bug rode along in the same fix commit. AgentRoutePicker.tsx resolved the current route for its trigger label against the full route list. If a session was pinned to a route that got disabled later (the classic case is local), the picker still showed agent . local in the trigger, even though the dropdown correctly hid disabled routes from the selectable list. The displayed selection and the selectable list disagreed. The fix adds && r.enabled to the lookup.
The recurring shape of this mistake
This is the same species of bug Part 22 ran into one level up: a mechanism generalizes in one place, and the thing that has to stay in sync with it is not forced to be touched together.
In Part 22 it was two pinning layers (session-level and in-turn) that lived in different packages, where the connector generalization got wired into one and not the other. Here it is two surfaces of the same admin API (the gateway handlers and the brain allowlist) that live in different packages, where adding the endpoint got done in one and not the other. The shared shape: a security or correctness invariant is split across files nothing forces you to edit together, and the second file is the one you forget.
I want to be honest about the structural cost. The exhaustive allowlist exists for a good reason. Its whole purpose is that the gateway’s internal surface, which includes reload and other operational paths, must stay unreachable from outside. An allowlist is the right shape for that, and “everything not listed is denied” is the right default. But the cost is real: every new admin endpoint needs a matching brain-side line, and the bug above is proof that the cost was paid in breakage. There is no test today that pins the two surfaces against each other, no compile-time link that fails the build when they drift. The only thing that catches the drift is running the UI and noticing the toast.
The honest costs
A few things this part does not solve, stated plainly:
localis still magic. The four system roles have no hardcoded names, but one user-owned route does. The brain’sclassifyRoute = "local"constant survived, because the sensitive-session classification still pins to the literallocalname. The commit advertises “no hardcoded names” and that is true for the system roles. It is not true forlocal.- Role resolution is now a network hop.
gwclient.RouteForRolehitsGET /v1/routes/roles, memoized for 30 seconds. The brain now depends on the gateway being reachable to resolve roles. On failure it returns"", which falls through to the gateway’s no-route error, so a turn degrades rather than crashes. But the dependency is new. - Delete has no in-use check, only a system-role check.
DeleteRouterefuses if the route holds a role. It does not check whether any session, mission, or agent is pinned to the route. Deleting a route a running session is pinned to will make that session’s next turn fail at the gateway. The system-role guard catches the catastrophic case; the in-use case is left to the operator.
Next: a slow model on a CPU box makes a long turn vanish with no trace, and five commits defend one contract, that a stream ends in exactly one terminal event.
Landed the same day, a89af0d and 42ba8da on main.