Command Palette

Search for a command to run...

2.2k
Blog

Giving a Portfolio Assistant Real Tools (and Five Models So It Never Goes Down)

Most site chatbots just answer FAQs. I wanted mine to act: scroll to a section, open a post, switch the theme. Here is how the client-side tool calls work, the quota trap I hit, and the fallback chain that keeps it free and online.

Most chatbots bolted onto a personal site do one thing: answer questions about the person. Useful, but passive. After looking at how the newer "agentic" demos let a model actually operate an interface, I wanted the assistant on this site to do the same. Not just tell you where the projects are, but take you there. Not just say "you can contact me", but open the chat with a drafted message ready to send.

It turned out to be a good exercise in two things that fight each other: making an LLM act on a real UI, and doing it on a free tier without the whole thing falling over. Here is what I learned.

Tools That Run in the Browser, Not on the Server

The usual tool-calling pattern runs the tool on the server: the model asks for getWeather, your backend fetches it, you feed the result back. But the actions I wanted are all things that happen in the visitor's browser: scrolling, navigating, flipping the theme, opening a panel. There is no server-side execute for "scroll to the projects section".

The Vercel AI SDK handles this cleanly. If you define a tool with no execute function, the SDK treats it as a client-side tool: the call is streamed to the browser and you run it there.

// route.ts (note: no execute)
const actionTools = {
  goToSection: tool({
    description: "Scroll the visitor to a section of the homepage.",
    inputSchema: z.object({
      section: z.enum(["about", "stack", "experience", "projects"]),
    }),
  }),
  setTheme: tool({
    description: "Switch the site's color theme.",
    inputSchema: z.object({ theme: z.enum(["light", "dark", "system"]) }),
  }),
  // openBlog, messageRobin, setSound, downloadResume...
}

On the client, useChat surfaces each call through onToolCall. I run the real browser action there and report the result back:

const { messages, sendMessage, addToolResult } = useChat({
  transport,
  onToolCall: ({ toolCall }) => {
    const output = runAction(toolCall.toolName, toolCall.input ?? {})
    addToolResult({
      tool: toolCall.toolName,
      toolCallId: toolCall.toolCallId,
      output,
    })
  },
})
 
function runAction(name, input) {
  switch (name) {
    case "goToSection":
      document
        .getElementById(input.section)
        ?.scrollIntoView({ behavior: "smooth" })
      return `Scrolled to the ${input.section} section.`
    case "setTheme":
      setTheme(input.theme)
      return `Switched to ${input.theme} mode.`
    // ...
  }
}

Each action also renders a small chip in the transcript ("Scrolling to projects"), so the visitor sees what happened.

The Quota Trap: One Question, Two API Calls

Here is the first thing that bit me. The natural flow is: model calls a tool, you return the result, the model continues so it can confirm in words ("Done, you are looking at the projects now"). The SDK makes that automatic with sendAutomaticallyWhen. It also quietly doubles your API usage. Every action becomes two model calls: one to request the tool, one to react to the result.

On a paid tier that is fine. On the free Gemini tier, where the budget is measured in requests per day, it is the difference between the assistant working and the assistant erroring out at lunchtime. And the failure was ugly: the action ran (you did get scrolled to the section), then the follow-up call hit the quota and surfaced a generic "Something went wrong", which looked like the action itself had failed.

The fix was to delete the second call. The action chip is already the confirmation. I dropped sendAutomaticallyWhen entirely, so a tool call is one request, the browser runs it, and the conversation rests. Half the usage, and the confusing post-action error is simply gone.

useChat({
  transport,
  // no sendAutomaticallyWhen: the action chip confirms, so we never
  // spend a second request reacting to the tool result.
  onToolCall: ({ toolCall }) => {
    /* run + addToolResult */
  },
})

Free Tier Math, and a Fallback Chain

The free Gemini tier gives a small number of requests per day per model. For the sharpest models that is around twenty. Twenty requests, across every visitor, for the whole day. For a portfolio that is genuinely not much.

The trick is that not every model has the same budget. One of the flash-lite tier models allows roughly five hundred requests a day. So instead of picking one model, I made an ordered chain and let the client walk down it whenever a model returns a quota or rate-limit error.

// models.ts: best/default first, big-budget workhorse second
export const GEMINI_FALLBACK = [
  "gemini-3.1-flash-lite", // ~500/day: the default workhorse
  "gemini-3.5-flash", // sharper, ~20/day
  "gemini-3-flash-preview",
  "gemini-2.5-flash-lite",
  "gemini-2.5-flash",
]

I deliberately lead with the high-budget model rather than the sharpest one. The point is uptime: the default almost never runs dry, and the premium models sit behind it for when it is briefly rate-limited. On the client, useChat's onError does the walking:

onError: (err) => {
  const canRetry =
    isQuotaError(err?.message ?? "") &&
    tierRef.current < GEMINI_FALLBACK.length - 1
  if (canRetry) {
    tierRef.current += 1 // drop to the next model
    setAutoRetrying(true)
    queueMicrotask(() => regenerate()) // silently retry
  }
}

It is sticky for the session, so once it has fallen back it stays on the working model instead of re-failing on the premium one every message. Combined, the chain offers roughly five hundred and eighty requests a day instead of twenty, and only when the entire chain is exhausted does the visitor see a message pointing them to the human chat.

Be Honest in the UI

Two small touches mattered more than I expected.

First, the assistant used to cheerfully say "Switched to dark mode" even when the site was already dark. So the theme and sound tools now read the live state first and tell the truth: "It is already in dark mode." A no-op should look like a no-op.

Second, while the chain is silently falling back, the loader does not just sit on "Thinking". It reads "Switching to Gemini 3.5 Flash", and a small live indicator under the composer shows which model is actually answering right now, with "(backup)" once it has dropped down. The goal is for the assistant to feel like a live thing running on a real model, not a spinner that might be stuck.

What I'd Tell Someone Building This

Client-side tools are the right primitive when the "action" is really a UI gesture; do not invent a server round-trip for something the browser already knows how to do. Watch your call count per interaction, because the convenient defaults can double it without telling you. And on a free tier, treat model choice as a chain, not a single pick: the cheapest reliable model as the floor, the sharp ones as a bonus while their budget lasts.

The whole thing costs nothing to run, does real work on the page, and degrades to "message me directly" instead of breaking. For a personal site, that is exactly the trade I wanted.

Related posts