← Back
All Scale
Operations platform for a demolition contractor

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.

ClaudeVercel AI SDKTool useNext.js 16Supabase RLSPrompt caching

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.

One sentence for the whole job: quote it, email it, and move it to Planning once they say yes.

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.

read
Runs now

Look up a job, a quote, an email thread. Checked against what this person is allowed to see.

inside
Runs now, with Undo

Change 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.

outside
Never runs in the loop

Anything 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.

It finds the client, thinks out loud, and drafts the quote from 121 past jobs. Then it waits for Create.
// 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.

The answer streams in with next steps as buttons. Nothing reaches the client until a person presses Create.

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:

  1. 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.
  2. 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.
  3. 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.
19,051
92
tokens re-cached per message
$0.049
$0.004
cache cost per message
every tool
−61%
tokens per step

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

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.