A model small enough to run on your phone can hold a perfectly good conversation. Ask that same model to also decide when it needs a tool, follow a multi-step procedure, and write a well-formed tool call (all at once, in one context window, the single span of text it can see at a time) and it quietly falls apart. This article is about the harness that fixes that: the scaffolding of prompts, checks, and loops built around the model. The harness splits one impossible job into three small ones, gives each the tiniest possible view of the world, and wraps the whole thing in a loop that catches failure without ever trusting the model to notice it failed.
This is the second article of the Agent series. Agent explains why a relationship-care app ships an agent at all, and Choosing Brains: the Model Bench explains which model ends up answering you at any given moment.
Here we take the hardest case that bench can hand us (the small, on-device model) and show how the harness makes it act like a competent agent anyway. As everywhere in this series, we are talking about harness engineering: prompts, sessions, loops, and signals. Nothing here is about training a model. Every model in this story arrives frozen (its weights never change), and all the reliability comes from structure.
First, the thing everybody builds, because with a large model it works.
The classic agentic loop (a loop in which the model can act, not just talk) looks like this. Put two things in front of the model: a system prompt (the standing instructions it reads before the conversation starts) and a catalog of tool schemas (machine-readable descriptions of functions the model may call: name, arguments, types). The model either answers in text or emits a structured tool call.
The harness executes the call, appends the result to the conversation, and asks again. Repeat until the model answers in text. Pixie runs exactly this loop for the big external models: the chat view model streams the provider's output, executes each tool call through a dispatcher (the code that routes each call to the right function) and loops. The loop is capped at five round-trips per user turn, so a confused model cannot spin forever.
Look closely at what that one loop quietly demands of the model, though. It is really three different jobs:
And it demands them simultaneously, inside a context that now contains the persona, the full schema of every tool (a couple hundred tokens each; tokens are the word-pieces a model reads, and the unit its context is budgeted in), the procedure text, and the whole conversation. A frontier-scale model (one of the giant models running in a data center) shrugs at this. The on-device model (Apple's built-in system model, the floor of the bench, reached through the FoundationModels framework, Apple's programming interface to it) does not. Its failure modes are specific and repeatable, and we hit all of them:
None of this is a moral failing of small models. It is a budget fact: attention and context are scarce, and the flat loop spends them on three jobs at once. The fix is the same one systems people always reach for: decompose, and give each part the least it needs.
When the user has skills enabled (a skill is a user-editable instruction bundle; more on that in a moment), the on-device provider stops running the flat loop and runs a three-level decomposition instead.
Three levels, and one rule repeated three times: every level sees the least it can get away with. The planner (L1) sees a one-line menu, not a single schema. The skill-follower (L2) sees the chosen skill's steps and one-line tool signatures (just a name, a key argument, and a purpose), never full schemas. Each tool-agent (L3) sees exactly one full schema and nothing else: not even the conversation. No level is asked to do more than one job, so no level needs the context that the other jobs would drag in.
Let's walk down the stack.
The planner's session (one self-contained conversation with the model) is deliberately spartan. Its instructions are the agent's base persona plus a short piece of plan guidance plus a menu: one line per enabled skill, like
Your skills:
- @search-info: The skill to use whenever you need to find some information the
user asked for — it checks every place that information could live.
- @check-schedule: Check the user's calendar — meetings, appointments, free/busy —
for a specific day or a relative one like today, tomorrow, this weekend, ...
Your tools:
- @current-date: You do NOT know the current date/day on your own — use this for
ANY question about today's date or day of the week.
Each @handle names a skill or a standalone tool in one shared @-namespace (skills
and tools draw from one pool of unique slugs, short hyphenated names, so every mention
is unambiguous). The
detailed steps behind each skill, and every tool schema, stay out of this context
entirely. The planner's whole job is a multiple-choice question: given this message,
which line of the menu applies, or none?
And here is the first genuinely load-bearing harness trick. The planner binds exactly
one tool, called plan. The model deciding to call plan is the "I need tools"
signal: a structured tool call the harness receives directly, not a sentence of prose it
would have to parse and hope it parsed right.
The plan argument is one sentence of
free text naming the chosen skills with @: "I'll use @search-info to find their PAN
number." The harness extracts the @handles from that sentence; the sentence itself is
kept as the rationale and handed down to L2 as its roadmap.
There is a subtle mechanical problem hiding in this design. FoundationModels' respond()
call runs the model until it produces a final text answer. But the planner has no real
tools bound. After calling plan, the model has nothing to conclude with, and the call
would never terminate.
The fix is blunt: the plan tool records the plan text and then
throws a sentinel Swift error (an error raised on purpose as a signal, not a real
failure) named PlanSignal, straight through the framework's loop.
The provider catches PlanSignal (plan captured, session halted, move to execution)
while any real error keeps propagating. We will see this trick again: when a model
cannot be trusted to stop, the harness stops it structurally, by throwing through the
machinery that runs it.
Around this sits a small amount of babysitting, all deterministic (plain code, no model judgment):
One more deliberate subtraction: there exists a planner-side tool for reading a skill's
detailed steps before planning, and we chose not to bind it. The planner routes on the
menu alone; the steps are followed at the level where the work happens. A single-tool
surface keeps "call plan" unambiguous for a small model; two tools would reintroduce a
routing decision inside the routing decision.
Between L1 and L2 sits a purely mechanical step: turning the plan's @handles into the
set of real tools to make available. The SkillRegistry resolves each handle against the
shared namespace (skill or tool) and expands skills recursively, because a skill's
body may itself mention other skills.
@search-info, for instance, says: check
@check-notebook first, then @fetch-file (itself a skill that wraps the file-finding
tools), then @grep-memory. The expansion walks all of it (guarded against cycles, so
a skill that loops back on itself cannot expand forever), removes duplicates, and
produces a flat, ordered toolset. That toolset, not the whole catalog,
is what L2 gets.
How skills are authored, shared, renamed, and why this menu doubles as the user's main alignment surface is its own story: Skills, Tools, and the @-Namespace.
L2 is a fresh session whose instructions contain: a lean preamble, the planner's rationale ("Your plan: I'll use @search-info to…"), the chosen skill's detailed steps (now, finally, in context, because now they are the job), and one compact line per available tool:
Your available tools:
- check_notebook(query) — search your distilled private Notebook (facts about the
user, a page per person in their life, ...); empty query browses recent notes
- find_file(query) — find one of the user's own files (docs/scans/photos/recordings
across their devices) by description and attach the best match
- grep_memory(query) — last-resort search of your full long-term memory log ...
Those are signatures, not schemas: name, key argument, terse purpose. The roughly 200-token JSON schema behind each tool (its full machine-readable argument spec) never enters L2's context. That is the entire context saving, and it is what lets the toolset grow without L2's per-step reading load growing with it.
But if L2 has no real tools bound, how does it call anything? Through the second
one-tool surface of the stack: a single meta-tool named call_tool, a tool whose
only job is to reach other tools. To use a tool, L2
calls call_tool with the tool's name and a plain-language instruction, not
arguments:
call_tool(tool: "find_file",
instruction: "the user's resume, to read their email address")
L2 is explicitly told: do not write tool arguments yourself; just say what you want. Crafting the real call is somebody else's job: the L3 agent below.
Two hard-won details live at this level:
call_tool's handler dispatches L3 inline and returns the result into the same
live session) is what makes the model keep genuinely calling, iteration after
iteration. This seam, and why one meta-tool beats binding many real tools, is the
subject of Mechanism: CallTool, One Tool to Bind Them.ToolLoopHalt, which
unwinds L2's respond() the same way PlanSignal unwound L1's. A small model will
happily search for the same file five times; the harness simply makes the fourth
attempt impossible.One curation note: not every bound tool is even shown to L2. The device-listing tool, for example, is plumbing (L3 defaults file searches to all of the user's devices in code), so L2 never sees it and never wastes a step reasoning about device scoping.
Every call_tool dispatch spins up an L3 tool-agent: a brand-new, ephemeral
session (it lives for one call, then is thrown away) bound with exactly one real
tool and its full schema. Its instructions are almost
insultingly narrow: you have exactly one tool; your only job is to call it once, with
well-crafted arguments, to satisfy the instruction you were handed; then briefly state
what it returned; never answer from your own knowledge.
This is where call-crafting quality comes from, because this is where the per-tool craft rules live: out of L2's sight, loaded only when relevant.
The file-finder's L3 is told
to build its query from several ranking keywords (document type plus the specific detail
wanted), never a single bare word. The calendar L3 is told to resolve "this weekend" into
concrete YYYY-MM-DD bounds before calling. Each rule rides with its tool, costs context
only when that tool is used, and is applied by a session whose entire world is that one
schema. A small model that flails when juggling twelve tools is startlingly good at
filling in one.
Two details make L3 safe and honest:
So far this reads like a straight pipeline. It is actually a loop, because plans fail: the planner picks a plausible skill and the tools come back with nothing usable.
Our first instinct was to add a fourth model: an LLM (large language model) assessor that read the executor's reply and judged whether the plan had succeeded. It was a mistake worth reporting. The assessor systematically misclassified grounded failures (honest answers backed by a real check, like "I checked your files and couldn't find a passport") as capability failures, and sent the planner back to try again.
The result was futile triple replans and roughly a minute per question, to arrive at the same honest "couldn't find it" the first attempt had already produced. The assessor was judging the one thing a model adds judgment to, and judging it wrong.
What replaced it is almost embarrassingly simple, and that is the point. The validate step is pure code: an empty reply fails; any substantive reply, including a grounded not-found, passes and ships. No model call, no latency, no misclassification. The lesson generalizes: put model judgment where only a model can judge (routing, crafting), and put deterministic code everywhere a rule can be written down.
When validation does fail, the harness replans, and this is where the loop earns its keep:
A last piece of hygiene guards the seams: anything streamed to the user is scrubbed of
leaked plan syntax (a stray "Plan:" preamble, a bare @handle), so the machinery never
shows through in the chat.
Step back and count the signals holding this together: PlanSignal halts L1 the instant
a plan exists; ToolLoopHalt halts L2 the instant a budget trips; validate-plus-replan
bounds the whole turn. Each is a structural stop (an error thrown through the
framework, or a branch in plain code), not a request politely made to the model. That is
the recurring shape of this harness.
Two placement notes. First, the L1–L2–L3 machinery engages on the on-device path when skills are enabled. With no skills selected, the on-device session runs flat with a handful of native FoundationModels tools, and the big external models run the classic loop from §1; their capacity is exactly what makes the decomposition unnecessary there. The harness is scaled to the brain it is harnessing.
Second, this article described one turn of cognition. Which of your devices is allowed
to run the agent's autonomous turns at
all is governed by a single seam called AgentRuntime,
which gates all generative agent behavior to the one device holding the
"driver's seat". A direct chat, by
contrast, runs wherever you are. That story belongs to Agent and
The Agent in Your Day.
The moral of this article is the series' moral in miniature. Between the flat loop that failed and the harness that works, the model did not change at all: the same small, frozen, on-device model runs every level of the stack.
What changed is structure: three one-tool surfaces instead of one many-tool surface; menus and signatures instead of schemas; structured signals instead of parsed prose; deterministic validation instead of model judgment; budgets and thrown halts instead of hope. Small models are not made reliable by asking more of them. They are made reliable by asking less, more precisely, more times.
The single most consequential seam in the stack (the one meta-tool that lets L2 drive any number of real tools through fresh L3 sessions) deserves its own close-up.
Next: Mechanism: CallTool, One Tool to Bind Them, the meta-tool seam in full detail: one-line signatures, inline L3 dispatch, and why the native loop is non-negotiable.