Part 13 gave the model connectors: Gmail, calendar, MCP tools. That was also the moment the threat model changed. Up to then, every request went through the gateway’s normal routing, and the worst case of picking the wrong provider was a bad answer or a wasted dollar. Once a tool can read your inbox, the worst case is different: raw email content, quietly, on a third-party model’s servers, because a routing rule pointed there.
I don’t think that tradeoff is mine to make silently. Some topics, health, finances, anything credential-adjacent, should never leave the house regardless of which cloud model happens to be smartest that week. This part is about making that a routing rule, not a hope.
Why a route, not a filter
The system already has task categories: reasoning, coding, mini, summarize, each an ordered provider chain that’s a row in task_routes (Part 3). The obvious move is to add one more category, sensitive, that only ever points at a local model, and to flip a turn onto it the moment something sensitive happens.
The alternative I didn’t take was content filtering: scan tool output for things that look like secrets or PII and redact them before they reach the model. That’s a losing game. An email is arbitrary text; there’s no reliable classifier for “this paragraph is private” that I’d trust to run silently. The route, by contrast, doesn’t need to understand the content at all. It just needs to know that a sensitive tool ran, and route everything downstream of that fact to a provider that can’t leak it off the machine.
// internal/brain/session/sensitive.go
type SensitiveTools struct {
Suffixes []string
Route func(context.Context) string
}
Suffixes names the tools that trip the pin, gmail_read and gmail_read_attachment today. Route is a function, not a static string, resolved fresh at flip time so a settings change applies to the very next call without a restart.
The pin has to be sticky, not per-message
The first version of this only forced the route for the rest of the current turn, using Agent.SetForceRoute: the moment a sensitive tool executed, every remaining LLM step in that turn used the pinned route instead of the turn’s normal one. That closed the obvious hole, but it left a bigger one open. The email content the model just read doesn’t evaporate at the end of the turn. It’s still sitting in the session’s context on the next turn, which started fresh on the normal route with a cloud model reading it anyway.
So the pin had to move up a level, from turn-scoped to session-scoped:
// internal/brain/chat/chat.go
func (s *Service) pinSensitiveRoute(ctx context.Context, events []session.Event, route, modelHint string) (string, string) {
if s.sensitive == nil || s.sensitive.Route == nil {
return route, modelHint
}
if !s.sensitive.SessionSensitive(events) {
return route, modelHint
}
if forced := s.sensitive.Route(ctx); forced != "" {
return forced, ""
}
return route, modelHint
}
SessionSensitive walks the whole event log, not just the current turn or the span about to be summarized, looking for any prior tool_execution event matching a sensitive suffix. If it finds one, every later turn, send, retry, the works, starts on the sensitive route before a single token goes out. Once a session has read email, it stays pinned to local models for its entire life, not just the turn that did the reading.
Side-calls are still calls
The turn-level pin closed the front door. It took a second pass to notice the back doors: memory extraction and session compaction both re-send turn content to their own routes, later, on their own schedule, entirely outside the pinned turn. A session that read a sensitive email gets that email distilled into turn memory, and eventually summarized during compaction. Both of those are LLM calls. Both were, before this fix, going out on the ordinary summarize route, cloud model included, regardless of what the turn itself was pinned to.
A leak through a side-call is still a leak. It doesn’t matter to the person whose finances just went out to a third party that the leak happened via a distillation call instead of the visible chat response. So SensitiveTools is shared, not duplicated, between the turn-execution path and both side-call sites: turn memory extraction and compaction’s summarize-and-extract pair now check SessionSensitive on the same event log and route themselves accordingly. Title generation gets the same treatment. One source of truth for “is this session sensitive,” consumed by every place that might send its content anywhere.
The hint has to lose
There’s a wrinkle specific to this gateway’s design (Part 3): a caller can pass an exact ModelHint alongside a route, and the hint outranks the route at the gateway’s resolve step, deliberately, so a user can say “no really, use this exact model” without needing a whole new route row. That’s a reasonable escape hatch for ordinary routing. It’s a hole here: if a hint survives the pin flip, it can point straight past the sensitive route’s chain to whatever cloud model the hint names, defeating the entire mechanism.
So the pin drops the hint in the same breath it forces the route:
if forced := s.sensitive.Route(ctx); forced != "" {
return forced, ""
}
Empty string, not the old hint. This has to be true for both the in-turn pin (Agent.SetForceRoute clears the turn’s hint the moment it flips) and the session-level pin (pinSensitiveRoute returns "" for the hint whenever it forces a route). A hint from before the pin fired is fine, it just gets dropped the instant the pin takes over, same as the route itself. Retry re-resolves both from scratch rather than reusing whatever the failed attempt had, so a retried turn can’t smuggle a stale hint back in either.
Making it a setting instead of an env var
The first implementation read SENSITIVE_TOOL_ROUTE at boot. That’s wrong for a privacy control: if I decide the floor needs to change, tightening it or loosening it because I finally set up a stronger local model, I shouldn’t have to edit deploy config and restart the stack to do it. The route now resolves per call from a sensitive_tool_route row in the existing settings table (Part 12’s feature-switches mechanism), with an empty value meaning the feature is off. The web settings panel gets a plain dropdown for it. The env var is gone entirely; there’s no boot-time value to fall out of sync with the runtime one.
This also means the pin is opt-in by construction. A deployment that never sets sensitive_tool_route behaves exactly as before, no forced route, no dropped hints, ordinary chains all the way through. The safety floor exists only where an operator has actually pointed it at something.
Why local models needed a second look
None of this matters if the local option is too slow to use. Ollama had been running containerized since day one, which is the obvious default, everything else in the stack runs in Compose. On macOS that default is quietly wrong: a container gets CPU only, no Metal, and a model like qwen3:32b couldn’t produce its first token before the gateway’s own request timeout. In practice, the sensitive route existed on paper but was unusable in the one deployment I actually run.
The fix wasn’t a code change to Timothy at all. Ollama now runs natively on the host, installed and served like any other Mac app, fully GPU-accelerated through Metal, and the provider row that used to point at a container name now points at host.docker.internal:11434, registered as an ordinary openaicompat provider, the same driver that already talks to GLM, Grok, and OpenRouter. Providers are still rows in a table (Part 3); the row just moved from pointing at a sibling container to pointing at the host. Nothing in the gateway needed a new driver, and the compose file lost a service, a volume, and a make target it no longer needed.
The honest tradeoff
Local models are slower and weaker than the cloud options I’d otherwise route to. Sensitive sessions get worse answers than they would if the pin didn’t exist. I think that’s the right trade for anything credential-adjacent, but I want it said plainly rather than buried in a routing table: pinning to local is a deliberate quality cut, not a free win.
It’s also a tradeoff that only pays off if a local provider is actually configured. Because the route is a runtime setting, a deployment with no Ollama registered and sensitive_tool_route left blank gets no floor at all, same as before this work existed. The mechanism doesn’t invent a local provider out of nothing; it just makes sure that if one exists, sensitive content is required to use it, all the way down through side-calls, retries, and the hint that would otherwise bypass it.
Next: a chat that survives page reloads, double-clicks, and dead turns.