Agents
A bounded model and tool loop that is also a durable workflow.
An agent run is not a new kind of execution. A loop compiles to an ordinary workflow, so a model turn is a step — made once however many times the run is driven — and an approval is an interrupt, surviving a deploy and answerable exactly once. Nothing here reimplements durability.
const publish = defineTool({
name: "publish",
description: "Publishes a brief.",
parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
args: publishArgs,
returns: briefId,
capabilities: ["db:write"],
approval: "always", // destructive, so a person agrees first
handler: (ctx, args) => ctx.db.insert("briefs", args),
});
export const review = loop({
name: "review",
agent: agent({ name: "reviewer", model, instructions, tools: toolRegistry([registerTool(publish)]), grant: ["db:write"] }),
phases: [{ name: "publishing", allowTools: ["publish"], maxTurns: 4 }],
budget: { maxTurns: 4, maxToolCalls: 2 },
grant: ["db:write"],
input, output, pricing, identity, prompt, finalize,
});A budget is required rather than defaulted. A loop with no bound is a loop that bills until somebody notices.
model comes from the catalog your application declares —
see Models and providers.
Tools are the only way out
A model produces text and requests. It never receives a database handle or a
fetch, so every privileged action has a name somebody wrote down, a schema
that validates it, a capability that gates it, and a policy saying whether a
person has to agree. That is not a sandbox — an inline tool is trusted code —
but it is an inventory, which is achievable: you can enumerate what an agent can
do, and the runtime refuses anything outside the list.
Model-generated arguments are the least trustworthy input in the system and are validated before a handler sees them. A tool's own failure comes back as a message the model can act on, reduced to its safe form.
What a schema does and does not cover
| Boundary | Checked against |
|---|---|
| Tool arguments | the tool's args schema, before the handler runs |
| Tool return value | the tool's returns schema |
| Step output | StepOptions.returns, on production and on replay |
| The run's answer | the loop's output schema |
That covers everything a tool exchanges. It does not cover the one thing that never goes through a tool: the model's final message, which is prose. A model that writes its tool call as text instead of making one produces a perfectly valid string —
", I'm reading the thread to answer.\n</parameter>\n</invoke>\n</invoke>"— and any output schema that accepts a string accepts that. Usually it means
the model's trained call format does not match the one the provider exposes.
The loop recognises it: an answer made of call syntax is not an answer, so
the model is told what it did and given another turn. A model that keeps doing
it ends the run as exhausted with reason malformed_output rather than
handing the mess to finalize, where it would be stored and shown to somebody.
The check is deliberately narrow — it matches closing tags and reserved control
tokens, never the words. An assistant that writes "I will invoke the publish
tool" or quotes <div></div> is answering the question, and an answer discarded
for looking technical is worse than the leak, because the caller cannot see what
was lost.
Capabilities only narrow
host grants ∩ agent grants ∩ phase grants ∩ what the tool requiresNo layer can add. A tool the run could not call is not described to the model at all — offering one and refusing it wastes a turn and teaches the model to keep asking.
Skills
A skill is a versioned bundle of instructions, an allowed tool set, capability
requirements, and an approval policy. A phase activates one by name, and doing
so narrows the phase: the skill's tools are intersected with allowTools
and its capabilities with what the phase already had. A skill never grants
anything and never receives ctx.db or ctx.fetch.
Instructions live in Markdown beside the skill and are inlined by
highx generate. Skill name and version are recorded on the run, and a replay
whose skill version changed underneath it is refused — its recorded turns
followed from text it was actually given.
highx eval skill <path> runs a skill's declared cases through your real loop and
tool path.
What it cost
createTenantObject({ agents: { budget: { windowMs: 86_400_000, maxCostMicros: 5_000_000 } } });Usage is recorded to the tenant's own usageTables, so "what has this tenant
spent today" is a reactive query. The budget is checked before a run starts:
a per-run budget bounds one run, and nothing about it stops a tenant starting a
thousand runs each comfortably inside it.
Cost is always computed from the rates you declare, never from what a provider reports back. A budget you can only enforce by asking a third party is not a budget — the answer arrives late, aggregated, and sometimes not at all.
Caching is priced in two directions
pricing: {
inputMicrosPerToken: 3,
outputMicrosPerToken: 15,
cachedInputMicrosPerToken: 0.3, // reading a prefix back: a deep discount
cacheWriteMicrosPerToken: 3.75, // writing it: a premium over base
}Reading a cached prefix costs a fraction of an ordinary input token; writing one
costs more. Both are subsets of inputTokens, and the remainder is billed at
the plain rate, so no token is counted twice.
Omit cacheWriteMicrosPerToken and writes are billed at the plain input rate,
which under-charges the first turn of every run that establishes a prefix. Set
it wherever your provider charges a premium — and check the current numbers, as
rates move.
Providers disagree about what they even report, so each adapter normalises:
Anthropic's input_tokens excludes cache tokens and is summed with them,
while OpenAI's prompt_tokens already includes them. What reaches your
ledger is the same shape either way.
Reasoning
reasoningTokens is recorded where a provider reports it. It changes no cost —
thinking is billed as output at the output rate — but for a reasoning model the
split between thinking and answering is usually the whole story behind an
expensive turn, and it appears on the gen_ai.chat span as well as in the
ledger.
Cost lands in two places for two questions. The ledger answers "what has this
tenant spent today"; the gen_ai.chat span answers "which turn was expensive",
which no aggregate can be taken apart into afterwards.
highx.agent.turn agent.phase=publishing agent.turn=1
├ gen_ai.chat gen_ai.request.model=claude-opus-5
│ highx.usage.cost_micros=4200
└ highx.tool.call highx.tool.name=publish approval=grantedEvery tool call the model asked for appears, including the ones refused for a missing capability and the ones a person declined. → Tracing
Watching a run as it answers
const stop = client.watchRun(runId, ({ turn, delta }) => {
setAnswer((text) => text + delta);
});A run's state is an ordinary reactive query — subscribe to workflowRuns for
that. This is the part that is not durable: the tokens, which arrive faster than
anything should be written to storage and are gone once shown. Watching requires
the permission to start runs.
Nothing is replayed. A run resumed after an approval makes no model call for the turns it already took, so it emits no deltas for them, and a run that finished before you watched emits none at all. Treat silence as normal and read the run's rows for what actually happened.
In React, use the bounded preview hook and pass the generated durable run result when it is available:
const stream = useRunStreamState(runId, durableRun);
// stream.text, stream.status, stream.approvalRequired, stream.errorThe durable result replaces the ephemeral preview on completion. Missed deltas
are not fabricated after reconnect, and duplicate frames with the same deltaId
are ignored.