An assistant that can't overstep
A job assistant that runs a small office's jobs from one sentence. It does what's safe straight away, drafts what isn't, and can never do more than the person typing could do by hand.
The problem
A demolition job touches a lot of screens: the quote, the plan, the permit, the client email, the date on the Gantt. The office manager knew every step — but doing one change meant five clicks in three places, and the real state of each job lived in her head.
The ask was simple: type what you want, and the app does it. The hard part is that some of those actions are harmless and some of them land in a client's inbox.
Three kinds of tool
The assistant has about forty tools. The one decision that shaped all of them: every tool declares what kind of thing it does, and the kind — not the prompt — decides whether it runs.
readLook up a job, a quote, an email thread. Checked against what this person is allowed to see.
insideChange a date, tick a task, move a status. Written through the person's own database client, so row-level security applies, and the old value is kept for Undo.
outsideAnything a client, supplier or Xero would see. The assistant only proposes it — a card the person reads and taps Send on.
A prompt can be talked out of a rule. A tool that has no code path to send an email can't. So "never message a client on your own" isn't an instruction to the model — it's a fact about the tools it's given.
// An "inside" tool: writes now, as the person, and keeps the old value.
async function setJobStatus(ctx, { jobId, status }) {
const before = await ctx.userDb.from("jobs").select("status").eq("id", jobId).single();
// ctx.userDb is the person's own client — row-level security applies,
// so the assistant can never do more than they could by hand.
const { error } = await ctx.userDb.from("jobs").update({ status }).eq("id", jobId);
if (error) return `Couldn't change the status: ${error.message}`;
await saveAction(ctx, {
tool: "set_job_status",
kind: "inside",
undo: { status: before.data.status },
});
return "Done. The person sees an Undo button.";
}// An "outside" tool: never sends. It saves a proposal and returns.
async function draftClientEmail(ctx, { jobId, to, subject, body }) {
const action = await saveAction(ctx, {
tool: "send_email",
kind: "outside",
status: "proposed",
input: { jobId, to, subject, body },
});
return `Drafted as card ${action.id}. Nothing is sent until the person taps Send.`;
}When the person does tap Send, or Undo, that outcome goes back into the next turn — "Since your last answer: the email card was sent" — so the model never claims something happened that didn't.
The loop
Each message gets at most eight steps. The spend caps — a monthly budget and a daily limit per person — are checked again before every step, and fail closed if spend can't be read. On the last step the model has to answer instead of calling another tool, so a confused turn ends with words, not a stuck spinner.
const result = streamText({
model: anthropic(MODEL),
system: [SHARED_CONTEXT, workflowPrompt], // big, byte-stable prefix → cached
messages: forModel(history), // old thinking blocks stripped
tools: { ...loadedGroups, ...deferred }, // the rest behind tool search
stopWhen: [
stepCountIs(8),
() => !withinSpendCaps(ctx), // re-checked before every step
],
prepareStep: ({ stepNumber }) =>
stepNumber === 7 ? { toolChoice: "none" } : {}, // last step must answer
});While it works, the person sees a short list of friendly steps — "Finding the job…", "Reading the job" — instead of raw tool names.
Making it cheap
The whole AI budget is fifteen dollars a month, so cost was a design constraint, not an afterthought. Three changes did most of it:
- A router in plain code. Before any model call, a few rules pick the right workflow — quoting, planning or client messages — from the words and where the chat was opened. Things the assistant doesn't do, like leave or pay, get a fixed reply with a link and no model call at all.
- Tool groups behind search. Only the tool groups the message hints at are sent up front. The rest are deferred behind Anthropic's tool search, and loaded only if the model asks.
- A prompt that only grows. The first version picked a fresh tool menu every message — which changed the prompt, which threw away the cache. Now workflows and groups can only be added within a conversation, so the cached prefix stays byte-for-byte the same. Live job details go in the user message, never the system prompt.
That lands at about two cents a message. There's an eval that checks prompt size with the free token-count endpoint, so a change that bloats the prompt fails in CI without running the model.
Small things that mattered
- VoiceFirst Whisper in the browser (WASM, ~1s a sentence, 77 MB). Then the browser's own speech API, with a pass that snaps near-miss street and job names to real ones by sound.
- Defaults, not questions"Get it ready" fills every gap with a sensible default — start six days after the client says yes, their usual SWMS — and lets the person change it.
- SuggestionsOne to three next steps, parsed from lines the model starts with >>, shown as chips.
- Empty stateThe placeholder rotates real examples from the person's own jobs — built in code, at no AI cost.
Where it stands
The assistant is built and running on staging; it isn't in front of real users yet. Every outside message type is still in "practice mode", which only drafts — so far, the app has sent nothing on its own. The plan is to let one kind of action go automatic only after theAI scorecardshows people keep it unchanged at least nine times in ten.
Simplified sketches for this page — names and details changed, none of the original code.