Cameron's know-how no longer has to fit in the system prompt.
v2 built the layer that proves Cameron's tools behave. This release changes where its instructions live: a skill is a folder of Markdown, loaded on demand, and the agent decides when it needs one. The first skill teaches Cameron to answer with a chart — and, just as deliberately, when not to.
Built in public alongside the Agentailor blog — one tagged release per article. The series hub, including where the roadmap goes next, lives at blog.agentailor.com/cameron.
What's new in v3
🧠 Skills: instructions loaded on demand
A skill is a directory under skills/ holding a SKILL.md in the standard AgentSkills format. Progressive disclosure is the whole point, and it is two steps: every valid skill's name and description go into the system prompt at startup — a line each, always in context — and the agent calls load_skill to pull the full body in only when it decides the skill is relevant. Instructions cost nothing until they're being used.
No bash, no sandbox, no agent-facing filesystem: a registry plus one read-only tool. Adding a skill is a file, not a code change.
🔒 Reading a skill approves nothing it tells you to do
load_skill is read-only, so it auto-approves. The instructions it returns may well lead to a mutating tool — and that tool is gated exactly as always. The approval boundary does not move because the agent read something first. Rule 2 holds: every capability passes the same gate, including this one.
📊 Charts the agent asks for but never sees
render_chart takes a spec — a read-only SELECT plus how to encode it — and returns two payloads to two consumers: a 69-byte summary to the model ({"ok":true,"chartType":"bar","points":5,…}) and the resolved rows to the client. The agent never handles a row, which is why there's no truncation machinery here: a chart of any size costs it the same handful of tokens.
It refuses rather than draws when the result would mislead — a column the query doesn't return (naming the real ones back), a truncated or oversized result, or an empty one, which is byte-identical whether a category is missing or misspelled.
Charts are drawn as SVG with no charting library, and the geometry is a DOM-free module so the decisions a screenshot can't check are unit tested: bar domains anchored to zero (a truncated baseline is the most common way a chart lies), gaps that break a line instead of interpolating across them, and the degenerate inputs — empty, all-null, flat, single point.
🎨 A categorical palette that survives colour blindness
The inherited --chart-1..5 tokens failed measurement as a categorical palette — ΔE 6.3 between two slots against a floor of 15, meaning readers with full colour vision couldn't reliably tell two bars apart. Two causes: it was a sequential ramp being used for categorical slots, and it was amber — the colour this app reserves for the approval boundary, so a five-bar chart read as five approval prompts. Replaced with 8 categorical hues in fixed order (the order is the colourblind-safety mechanism), a separate sequential ramp, and a dark column stepped for the dark ground rather than flipped.
📈 The expense-reporter skill — and the restraint half
Four steps: whether to chart at all → which type → how to shape the query → how to read it back. The restraint is the part that matters. Nothing in code stops the agent charting a single figure, and a skill that only encouraged charts would make answers worse — so it says a single number is a sentence and a short ranking is a table.
🎯 4 eval cases for a defect class the suite couldn't reach
A capability the agent must choose to consult, and choose not to overuse. A unit test proves load_skill returns the body; only a model can show whether the agent reaches for it, reads it before acting, or leaves it alone.
| Case | Grades |
|---|---|
chart-request-loads-skill |
activation |
skill-read-before-charting |
order — toolCalledBefore(load_skill, render_chart) |
trend-question-picks-line |
chart-type selection |
single-figure-needs-no-chart |
the over-triggering guard |
🐛 A prompt defect the evals found
The skills section said "when a task matches a skill" without defining matching, and haiku fell back to lexical overlap — loading the skill for "show me a chart" but never for "chart my spending": 0/5, deterministic, identical trajectory every run. Sonnet loaded it either way, so the instruction was carrying the difference, not the model. The fix states the principle — judge the KIND of work, not the vocabulary — with deliberately no example phrasings, since listing synonyms would make the eval pass by feeding it the answer. Activation went 0/5 → ~6/8 on haiku with no vocabulary shared between prompt and case.
It also surfaced a harness bug older than this work: only approval and config cases reset the fixture, so an approved log_expense left a row behind and shifted a Dining total the next case asserted. Three existing cases passed only because they ran first. Case order was load-bearing and silent.
Also in v3
⚙️ Pick your provider and model in the app — a new /settings page stores the choice in the config table, replacing localStorage and env-derived defaults. Nothing is inferred: with no provider stored the app says it's unconfigured rather than guessing. Adds openai-compatible for any OpenAI chat-completions endpoint (Ollama, vLLM, LM Studio, Groq, OpenRouter, DeepSeek) — a local runtime needs no API key at all. Keys stay in the environment, are never displayed, and apiKey is always passed explicitly so OPENAI_API_KEY can't leak to a third-party host. The model rows are deliberately absent from the set_config allowlist: Cameron cannot change which model it runs on.
🚫 The approval bypass is gone from the product — the template shipped a gate on/off switch in the composer that disabled approval for every mutating tool, which contradicts Cameron's first rule. Removed from the toolbar, the context, the query param, the OpenAPI schema, and the request type. One caller keeps a bypass: the eval harness, which drives the agent in-process and has no human to answer an interrupt. approvalGate.test.ts pins that boundary at each layer, because the failure it guards against is silent — a bypass wired back in would still typecheck, still stream, and quietly write to a real ledger.
🗂️ Recover refused CSV rows instead of re-importing the file — a real 451-row export mixed two date shapes; 9 rows were refused, and the only way to rescue them was re-running the import, which imported a second copy. Three passes later the ledger held triplicates while skippedDuplicates: 0 said nothing was wrong — dedup keys on (source, external_id) and NULL never equals NULL in Postgres, so a file with no id column isn't deduplicated at all. Now: read_csv_rows reads refused rows by number for one-at-a-time recovery via log_expense, validate_csv_import is a separate mandatory tool (not a dryRun flag — approval is keyed by tool name), and duplicate detection reports itself honestly as "active" | "unavailable".
🃏 A tool call and its result render as one card — the wire delivers them as two messages, and the template drew two cards. They're now paired by tool_call_id (never by adjacency: results arrive when they resolve, and a call paused at the gate never gets one), grouped when consecutive, and collapsed by default. Two things are never collapsed: a gated call's arguments, because you cannot approve what you cannot read, and a chart, because it's the answer, not a detail of one.
🩹 Streaming order and a gate that wouldn't show — the tool pump awaited the whole run whenever an artifact was merely absent, which for every tool but render_chart is always, serializing each turn into all AI text → all tool results. Fixed with a positive signal (artifactTools.ts). The approval gate also keyed off list position, comparing a deduplicated index against the original length — so whenever dedup removed anything, the APPROVE button never appeared.
✅ 266 unit tests, still free and offline — no model, no network, no database, no API keys.
Upgrading from v2
No breaking changes and no new migrations.
One action required: provider and model now live in the database, not localStorage or env defaults. On first start after upgrading, Cameron will report itself unconfigured — open /settings, pick your provider and model, and save. Your API keys stay in .env exactly as before.
Skills need no setup: skills/ ships in the image, and expense-reporter is available immediately. Adding or editing a skill requires a restart, since the registry reads once at startup.
Full Changelog: v2...v3