Anton, chapter 4: Explicit contracts
The week began with a one-line configuration change.
Anton's local inference moved from Ollama to vLLM. The diff was almost nothing; the serving architecture underneath it was not. Ollama had been ideal for getting started. vLLM offered the concurrency and batching I wanted beneath a system that several people might rely on at once.
It was the first of many changes that week with the same shape: take something Anton was doing implicitly and give it an explicit contract.
Store generously, retrieve narrowly
Memory was the first system to outgrow its original design.
The initial version was deliberately simple: Postgres rows tagged by user. That was enough to learn what memory needed to become, but not enough to retrieve the right information reliably.
I still resisted adding embeddings. At the scale of a family knowledge base, trigram matching was sufficient, and an embedding pipeline would have introduced cost and machinery before either was justified.
Instead, retrieval became more deliberate.
Every fact gained provenance, so it could be traced to its source. Queries were biased toward the relevant domain: calendar questions favoured calendar facts, media questions favoured media facts. Short conversational requests received a narrow recall window; research-like questions received a larger one. A working-memory scratchpad carried the immediate conversation, while an LLM reformulated queries before search because people rarely ask for information using the same words in which it was stored.
Later, I added summaries of past conversations, but only when a request had temporal intent.
Always injecting episodic memory feels helpful in theory. In practice, it is context bloat disguised as intelligence.
The principle became:
Store generously. Retrieve narrowly.
Or, more technically: filter at load time, not write time.
Scheduling becomes a domain
Scheduling had started as a thin wrapper around BullMQ. That was no longer enough.
Half the things I wanted Anton to do were recurring: morning briefings, weekly reviews, reminders to call someone, regular checks that should happen without being requested each time.
Schedules gained time zones, duplicate merging, execution history, manual runs, silent mode, and a UI that made them understandable. They stopped being plumbing hidden behind other domains and became a domain in their own right.
At the same time, LiteLLM became the gateway in front of every model provider.
One system should decide where model calls go. Individual agents should not need to know which provider currently serves them, how it is authenticated, or what fallback should be used.
I gave the models codenames: Sunny, Haiku, Oscar, Gandalf, Gizmo, Gatsby, Merlin, Gustav.
It felt mildly silly until the first configuration change. Provider names, versions, prices, and capabilities move constantly. A stable internal name allows everything above the gateway to remain still.
Indirection is useful when it protects the rest of the system from change.
Naming compounds
A naming mistake from the previous week also caught up with me.
I had used "skill" for two different things: a typed function in code and a reusable prompt stored in the database.
Both meanings made sense in isolation. Together, they made every conversation about the system harder.
The reusable templates became prompts. A skill became one thing only: a typed capability with a runtime contract.
The cleanup took a few hours. Leaving the ambiguity in place would have charged interest indefinitely.
Naming is architecture because names determine whether people can reason about the system without first translating it in their heads.
One way to run a skill
The new defineSkill() contract required every skill to declare the same things: its inputs, outputs, scopes, and handler.
Then the skill runner became its own service.
That separation gave me hot reload, per-skill metrics, cleaner scope isolation, and a path toward sandboxing later. More importantly, every domain began invoking skills through the same entry point.
One contract. One runner. One way to add a capability.
This is the kind of work that can feel slower than adding features. Nothing visible happens for the family when two invocation paths become one.
But every future feature gets cheaper.
An honest record of execution
Traces became durable too.
The first trace viewer read checkpoints from Redis. That was useful while debugging a live request and nearly useless afterward. Execution traces moved into Postgres, where they survived failures and could be searched from the UI.
I enforced one invariant: one trace per request.
Once that was true, Anton's behaviour became queryable. I could inspect what happened last night or last week rather than trying to reproduce it from memory.
Permissions followed the same pattern. Filtering moved into runAgent(), the one path every caller had to use.
Before that, each caller was responsible for applying permissions correctly. Scheduled jobs, mesh calls, direct invocations, and the parent agent could subtly diverge.
A security rule enforced in four places is four opportunities to forget it.
The right place for a rule is the chokepoint every path must cross.
Give the model a smaller menu
The architecture view in the dashboard began drawing itself from the runtime configuration. For the first time, I could see Anton's shape rather than infer it from files and logs.
The picture revealed a problem: the parent agent had 63 tools.
The model was being asked to choose correctly from a menu no person would want to read.
I replaced the flat toolset with ten subsystem delegates. The parent now routed and synthesised; each delegate handled a much smaller set of capabilities.
The improvement was immediate.
The lesson from the calendar agent had scaled to the whole system: models are good at selecting from a small, coherent menu and unreliable when everything is presented at once.
A better prompt would not solve a badly shaped choice.
Behaviour becomes configuration
Output validation entered the agent loop, along with deterministic confirmations that a tool had actually completed.
This eliminated one of the worst chat failure modes: the tool succeeds, but Anton says nothing.
Then came prime directives, a small set of rules enforced above any individual prompt. The first version was too verbose, so each directive was reduced to a single line.
The precedence became clear: directives, then agent prompt, then prompt template, then user message.
The non-negotiable rules belonged in code. Everything else should remain editable.
That led to the final change of the week: every agent prompt moved into the database.
No hardcoded fallback. One row per agent, editable from the UI and versioned.
Anton's behaviour stopped being something I had to deploy and became something I could configure, test, and roll back.
By Sunday night, inference, memory, schedules, skills, traces, permissions, delegation, validation, and prompts all had explicit shapes and single entry points.
The week had been almost entirely plumbing.
But plumbing is what allows everything above it to change without flooding the house.
The lesson I took forward was simple:
Turn implicit conventions into explicit contracts.
Once a thing has a shape on disk and one path through the code, the rest of the system becomes much easier to change.