Timothy July 23, 2026 ~10 min read

Timothy Part 14: Agents, Not Modes

By the time the control plane shipped (see Part 12), the composer had accumulated three separate knobs that all did roughly the same job: a task-category picker for routing, a skill chip you could pin onto a chat, and an implicit “which prompt am I even talking to” that only the research page answered directly. Adding a second locked assistant would have meant a fourth knob. That was the signal to stop and ask what a session actually needs to start.

Why agents instead of modes

The research assistant (PR #32) was the first hint. Locking a session to one skill and forcing a ”## Sources” section wasn’t a chat feature bolted onto the general assistant, it was a different assistant: same model access, different prompt, different constraints, different UI. Once that existed as a special case, the question was obvious: why does every other session get its routing, tools, and skills chosen by three uncoordinated pickers instead of one coherent profile the same way research does?

An agent is that profile: a prompt overlay, a route (its model chain), a skill allowlist, a tool allowlist, and a memory on/off switch, stored as one row. The task-category picker is gone. The composer carries an agent picker, and the agent decides how the turn gets served.

// internal/brain/agents/agents.go
type Agent struct {
    ID                string
    Name              string
    Description       string
    PromptOverlay     string
    Route             string
    Skills            []string
    Tools             []string
    Memory            bool
    IsDefault         bool
    Enabled           bool
    ReviewRoute       string
    BudgetUSD         *float64
    ApprovalAllowlist []string
}

Empty Skills or Tools means “everything allowed,” not “nothing.” A profile only narrows what it explicitly narrows. ReviewRoute, BudgetUSD, and ApprovalAllowlist are meaningless to a chat-only agent and stay at their zero values; a mission-capable agent sets them, and missions read this same table rather than a parallel one, so a mission and a chat session serving the same name behave consistently.

Exactly one agent is the default: the zero-click choice when a session starts without picking anything. Deleting or disabling the default is refused in code, not left as a UI convention, because a session must always have somewhere to land.

Why routes instead of task categories

The gateway had shipped with task categories (reasoning, coding, mini, and so on) since the beginning (see Part 3). They mapped cleanly to “what kind of call is this,” which was fine when the caller was a fixed API path. Once agents own their own routing, the category concept and the agent concept were describing almost the same thing through two different vocabularies, and every new agent meant either reusing a category that didn’t quite fit or inventing a new one that only that agent used.

Task categories are gone. routes(name, chain, strategy, enabled) is the one routing primitive left, referenced by agents and by a small set of fixed system names (default, summarize, embedding). An agent just names a route; the route owns the chain.

Scored routing

A route’s chain used to mean one thing: try entry one, then two, then three, in the order they were written. That’s still the default (strategy: "ordered"), but three more strategies now exist: auto, price, and latency. They score each chain entry from declared model prices plus recent ledger stats and reorder the try sequence at resolve time.

// internal/gateway/router/router.go
var strategyWeights = map[string]struct{ price, latency, tps float64 }{
    "auto":    {price: 0.6, latency: 0.1, tps: 0.05},
    "price":   {price: 0.9, latency: 0.02, tps: 0.02},
    "latency": {price: 0.1, latency: 0.6, tps: 0.1},
}

Each factor is normalized against the best candidate in the chain (a cheapest-model, fastest-latency, highest-throughput reference point), so the weights are relative importance, not absolute units. Uptime isn’t a weight, it’s a multiplier on the whole score: an unreliable provider sinks under every strategy, because no price advantage outruns failing most of its requests. A brand-new entry with no ledger history yet scores each missing factor as neutral (0.5) rather than zero, so it isn’t punished for simply not having run yet.

The stats themselves come from the same cost ledger Part 3 built: time-decayed hourly aggregates of success rate, mean latency, and output tokens per second, per provider and model. Scoring reuses data the ledger was already recording for the dashboard, nothing new is measured.

Sessions stick. Once a session has a successful turn on a provider, that pairing is tried first on the next turn, ahead of the scored order, as long as it’s still a member of the route’s chain:

// Sticky is a preference, never an expansion: it must already be a
// member of this route's chain, so a route change or chain edit
// naturally breaks the pin.

The reason is prompt caches (D-018, see Part 6): a marginally better score elsewhere is usually worth less than staying on the provider that already has this conversation’s prefix cached. Failover only advances past a sticky or scored pick when the failure is one another provider could actually fix; a 4xx from a malformed request stops the chain rather than burning through every remaining candidate on a request that was never going to succeed anywhere.

The visual pipeline editor (PR #71) makes this legible instead of theoretical. Ordered routes still reorder by dragging cards; scored routes render the resolved try order with a score bar per entry and a strategy badge, so “what will actually serve the next request” is something you can look at instead of infer from three separate settings fields.

Providers as presets, not blank forms

Configuring a provider used to mean knowing its driver name, its base URL, and its exact model IDs by heart. PR #43 turned that into a preset picker: choosing “Anthropic” or “GLM (Z.ai)” or “AWS Bedrock” prefills the driver, base URL, key hint, and a validation model, and a custom-endpoint preset stays for anything not on the list.

// web/src/components/settings/presets.ts
{
  id: 'glm',
  name: 'GLM (Z.ai)',
  driver: 'openaicompat',
  baseURL: 'https://api.z.ai/api/paas/v4',
  requiresKey: true,
  defaultRef: 'ZAI_API_KEY',
  keyHint: 'Create one at z.ai.',
  validateModel: 'glm-4.7-flash',
}

The important part isn’t the convenience, it’s that creating a provider now validates before it commits. A POST /providers/validate endpoint runs the same one-token probe Test runs against a saved provider, but against config that hasn’t been persisted yet. A typo’d base URL or a dead API key fails in the add dialog instead of silently becoming an unhealthy row that only shows up as a routing failure days later. The presets were later trimmed from a longer list down to six curated ones (PR #57) once it was clear most of the extra entries were never going to be filled in with real pricing data anyone had actually checked.

Newly connected providers also auto-bootstrap the fixed system routes (internal/gateway/router/bootstrap.go, D-033 follow-up): an empty default chain gets seeded with the provider’s cheapest chat-capable model, and a non-empty chain gets that model appended as the last entry. Existing priority order is never reordered or removed, so a hand-tuned chain only ever gains a fallback.

Secrets by reference, now with a choice of backend

Providers had always stored a credential_ref name, never a value (Part 3’s rule). What changed is what a ref can resolve against. The default backend is still an environment variable, but PR #51 added a UI-managed default so a fresh deployment doesn’t need to pre-populate deploy/.env with every provider’s key before the first login, and the secret store now supports file-backed refs alongside env vars: one file per secret in a mounted directory, the Docker/Kubernetes secrets convention, matched by ref name. The trust boundary is the mount itself, set up outside Timothy; the code just reads whichever backend a ref names.

The one rule that never bent: whatever the backend, the providers listing endpoint returns ref names only. A resolved secret value has no path to a response body, tested the same way it was in Part 3.

Spend budgets: the ledger’s honesty, enforced as alerts

The cost ledger had recorded every call’s dollar figure since it existed, but nothing read the running total against a limit. PR #41 added spend_budgets, a day and a month USD limit, edited from Settings:

// internal/gateway/ledger/budget.go
type BudgetWindow struct {
    LimitUSD *float64 `json:"limit_usd"`
    SpendUSD float64  `json:"spend_usd"`
    Over     bool     `json:"over"`
}

Windows are UTC calendar boundaries, computed the same way regardless of the dashboard’s local timezone, so the alert state and the tile you’re looking at can’t quietly disagree about which day it is. Budgets never block a request. This follows directly from the never-guess-a-cost rule in Part 3: a missing price stays NULL rather than an assumed zero, and by the same logic a spend limit is a fact you’re told, not a request the system silently refuses on your behalf. The status also exports as Prometheus gauges (timothy_spend_usd, timothy_spend_budget_usd) for anyone who wants real alerting instead of a dashboard banner.

Auto dispatch: picking an agent without being asked

With more than one agent, the obvious next question is whether the user has to pick one at all. PR #53 added an “auto” sentinel: when a session starts without naming an agent, the caller’s message goes through a cheap classification call listing each enabled agent’s name and description, and asking for a number back.

// internal/brain/agents/dispatch.go
func Dispatch(ctx context.Context, classify Classify, message string, candidates []Agent, fallbackName string) string {
    if len(candidates) == 0 {
        return fallbackName
    }
    if len(candidates) == 1 {
        return candidates[0].Name
    }
    ...
}

A single enabled agent skips the call entirely, there’s nothing to choose between. A closed numeric answer is far more reliable to parse out of a small, cheap model than free-form agent-name text, and the parser only accepts a leading run of digits: anything else (a model answering with the agent’s name, or padding with an explanation) is unparsable rather than guessed at. Any classifier failure, timeout, or unparsable reply falls back to the caller’s default agent, an ergonomics layer on top of a system that has to work fine without it.

What still isn’t there

Auto dispatch classifies from name and description alone; it has no memory of which agent handled similar messages well before, and it can’t be corrected in place, only overridden by picking a different agent next time. Scored routing has never been tested against a real multi-provider price war, only against ledger data where providers are each cheaper or slower in unambiguous ways, so the score math is honest but not yet proven under close competition. Validate-on-create catches a dead key or a wrong URL; it does not catch a key with the wrong scopes, which still surfaces later as an unhealthy provider, same as before.

What this replaced

Before this milestone: a task-category picker choosing a model chain, a skill chip pinning a persona onto general chat, and a hand-typed provider row for anything beyond the first few. After: one agent picker deciding prompt, tools, skills, and memory together, a route underneath it that can pick its own provider order from live data instead of a fixed list, and a provider dialog that validates before it saves. The unified picker (PR #99) folded the agent and route choices into a single dropdown everywhere the composer renders, closing the last seam between the two.

Next: missions, a state machine for long-running agent work that survives restarts.

// end of article — process exited with code 0

// share:Twitter / XLinkedIn

// comments

loading...