← All the guides

Step 4 of 6

Give it a voice

Learn to ask your assistant about your own life out loud, from your phone, and hear it answer from your data. Sixty lines of loop and one tool. About 40 minutes to read, a day to do.

What it does

  • Takes a sentence, decides which of your tools to call, calls them, and answers from the result
  • Writes back to Airtable mid-conversation, so telling it something updates the record
  • Runs from an iOS Shortcut, which means it works in the car with your hands on the wheel

Why I built it

The brain from step 2 is queryable and nobody queries it. I had a working Airtable base for four months and opened it twice a week.

Voice closed that. I ask Gerry how I slept while making coffee and it tells me, and that is a different behaviour from opening an app.

The loop below is about sixty lines. Every tool you add after the first one is a schema and a function, so the second tool takes ten minutes and the fortieth takes ten minutes. I am at 46 and the shape has not changed since the first week.

Build it

1.

Define one tool, and make it a read

A tool is a JSON schema the model sees and a function it triggers. Start with a read, because a model that hallucinates a read shows you the bug harmlessly. A model that hallucinates a write puts a row in your money table.

The description field is the part that matters. The model chooses on that sentence alone, so write it as an instruction about when to reach for this tool.

lib/agent/tools.ts

export const TOOL_DEFINITIONS = [
  {
    name: "get_sleep_trend",
    description:
      "Sleep hours for the last 7 days with an average. Use when asked how " +
      "they slept, about sleep this week, or whether sleep is improving.",
    input_schema: { type: "object" as const, properties: {}, required: [] },
  },
];

export async function executeTool(name: string, input: any) {
  if (name === "get_sleep_trend") return getSleepTrend();
  throw new Error(`Unknown tool: ${name}`);
}
2.

Write the loop, and cap it

The model answers, or it asks for a tool. If it asks, you run the tool, hand back the result and ask again. That repeats until it stops asking.

Cap the rounds. Mine sits at 10. Without a cap a confused model loops on a failing tool until the request times out and the bill is already spent.

app/api/agent/route.ts

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";

const client = new Anthropic();
const MAX_TOOL_ROUNDS = 10;

export async function POST(req: NextRequest) {
  const { message } = await req.json();
  const messages: any[] = [{ role: "user", content: message }];

  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
    const res = await client.messages.create({
      model: "claude-opus-5",
      max_tokens: 2048,
      system: SYSTEM_PROMPT,
      tools: TOOL_DEFINITIONS,
      messages,
    });

    if (res.stop_reason !== "tool_use") {
      const text = res.content.find((c: any) => c.type === "text");
      return NextResponse.json({ reply: text?.text ?? "" });
    }

    messages.push({ role: "assistant", content: res.content });
    const results = [];
    for (const block of res.content) {
      if (block.type !== "tool_use") continue;
      try {
        const out = await executeTool(block.name, block.input);
        results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
      } catch (err: any) {
        results.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: `Error: ${err.message}`,
          is_error: true,
        });
      }
    }
    messages.push({ role: "user", content: results });
  }

  return NextResponse.json({ reply: "That took too many steps. Ask me a narrower question." });
}
3.

Put the register in the system prompt

If you did step 1, this is where your character sheet goes: paste character.md into the system prompt, under the rules.

An assistant with a default voice sounds like every other assistant, and you will stop using it. Mine is told to be dry, British and brief, with a banned-word list and a rule against summarising itself.

Feed it the facts it needs on every turn as well. Your timezone and how you work. Gerry knows I have ADHD and gives me one action rather than a menu of six, and that single line changed the output more than any model change since.

app/api/agent/route.ts

const SYSTEM_PROMPT = `You are the user's personal assistant.

Register: dry, brief. Understatement over enthusiasm. State the thing, then stop.

Rules:
- UK English throughout: organised, realised, colour.
- No em dashes. Use commas or full stops.
- No sycophantic openings. No trailing summaries.
- After every write, confirm the result in one sentence.
- One action, not a wall of options.

Context:
- Timezone is Europe/London.`;
4.

Be honest about what it reaches

A system built in public drifts towards claiming more than it does. My rule is that a thing counts as live only when a tool for it exists in the tool file. Built and not wired is its own status, and I show that count on the public map.

Of my 26 agents, 15 are wired and 5 work perfectly well while the assistant cannot reach them. Publishing that gap is more useful than publishing 26.

5.

Get it on your phone by voice

An iOS Shortcut with three actions. Dictate Text, then Get Contents of URL posting that text to your agent route, then Speak Text on the reply. Assign it to the Action Button or ask Siri to run it.

Authenticate it with a bearer token in the header, checked in your middleware alongside the cookie. The Shortcut has no browser and no cookie jar, so a cookie-only check locks your own phone out.

Shortcut, Get Contents of URL

URL     https://your-agent.vercel.app/api/agent
Method  POST
Headers Authorization: Bearer <the token from your env vars>
        Content-Type: application/json
Body    { "message": "<Dictated Text>" }

Next

Core rebuild: the first four agents

Read it →