Typed useChat, tool parts, and the usage panel
The route streams, the tool reads real numbers, the quota refuses gracefully — and the user still sees a textarea bound to useState rendering raw text. That was the smoke-test client from the streaming-route lesson, a deliberate placeholder. This lesson replaces it with the real thing: a typed useChat that renders text bubbles and invoice-stats cards across every tool-part state, plus a panel showing how much of today’s token budget is left.
Asking “how many overdue invoices do we have?” flashes a card-shaped skeleton, then renders the real numbers, then the assistant’s text bubble citing the count. Trigger the quota refusal and it surfaces as a friendly toast while the input stays usable. Here is the finished right rail — the usage bar on top, the chat below, an assistant turn mid-flight rendering the stats-card skeleton:
Your mission
Section titled “Your mission”This is the last build slice, and it is the client half of every LLM surface you will ever ship. The route, the tool, and the quota are already standing; what is left is the part the user actually touches. The single idea that makes the whole thing honest is the type contract. You typed the message in the tool lesson as InvoiceUIMessage = UIMessage<unknown, never, InferUITools<InvoiceTools>> — that InferUITools generic is what you cash in now. Drive the chat with useChat<InvoiceUIMessage> and, inside the tool-getInvoiceStats branch, part.output is the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } shape — a union with the { error } arm — not unknown. No as casts anywhere in the render. Drop the generic and every switch branch needs a cast, and a cast is a place where the client’s idea of the data and the server’s idea of the data can silently drift apart. The typed message is the seam that keeps them in lockstep; this is the same InferUITools and typed-UIMessage machinery taught when you built the tool registry, now consumed.
A few v5 shapes you have met before resurface here. Input state is a local useState — version 5 of the SDK stopped auto-managing the input field, so you own it now, the same change you saw when you first wired useChat and DefaultChatTransport. The submit handler guards on status === 'streaming' || status === 'submitted' plus an empty-input check before it calls sendMessage — the identical in-flight gate that blocked double-submits with useTransition back in the optimistic-mutations work; a chat input is just another place where a second click while the first request is in flight is a bug, not a feature. The render walks messages.map then parts.map and switches on part.type: a text part is a bubble, a tool-getInvoiceStats part spreads the whole part into <InvoiceStatsCard {...part} />, and a default branch returns null so any unknown or still-transient part type degrades quietly instead of crashing the tree. Spreading the whole part rather than picking off state, input, and output as separate props is what lets the discriminated union narrow inside the card — break the part apart at the call site and you throw the narrowing away before the card ever sees it.
The card is where the per-tool UX lives. It takes the entire UIToolInvocation as its prop and switches on part.state across all four lifecycle states. The state worth lingering on is input-available: the tool’s arguments have arrived and execute is running, so you render a card-shaped skeleton — stat-slot placeholders laid out exactly where the real numbers will land — not a generic spinner. The shape of the skeleton tells the user what is coming; a spinner says only “something is happening.” This is the per-tool-skeleton principle from the tool-parts lesson, made concrete: a five-tool chat would carry five skeletons, each shaped like its own result, and this chat carries one. When the output arrives, the card guards 'error' in part.output before destructuring, because the output union carries that { error } arm the tool returns on failure — a tool error becomes a one-line “I couldn’t load those stats” message, never a half-rendered card.
The usage panel polls GET /api/usage every ten seconds. That polling lives in the one useEffect this whole feature is allowed — polling an external system is exactly the escape hatch the React-effects discipline reserves an effect for. It opens an AbortController, fetches with the signal, and on unmount it both aborts the in-flight request and clears the interval. Ten seconds is the simple default for a personal-quota surface where the number drifts slowly; a team-wide billing dashboard that needs the spend reflected the instant it happens would reach for a server-sent signal or a re-poll triggered off the chat’s own onFinish — name the refinement, do not build it. The quota refusal itself surfaces through useChat’s onError as a sanitized toast, and the input stays enabled — tomorrow the budget resets. Rendering a distinct message specifically for quota_exceeded by parsing the response body is a refinement you could add; the default is one friendly toast for any failure.
Two things to resist. Do not persist messages — this surface is scoped to in-memory chat state, so a refresh loses the conversation, and that is fine for now; durable history is a forward pointer, not this lesson’s job. And the chat lives in the /invoices right rail, co-located with the data it discusses, not behind a separate /chat route. That co-location is a deliberate UX call: the user reaches for “how many overdue?” while looking at the list, so the answer belongs next to the list. The page already mounts the panel and the chat for you in its <aside> — you fill the three components, not the page.
submitted → streaming → ready, flashes a card-shaped skeleton while the tool’s input is streaming, renders the real numbers, then an assistant text bubble citing the count.tool-getInvoiceStats branch, part.output is the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } type (a union with the { error } arm), not unknown, end to end — no casts.POST /api/chat.sendMessage, message.parts, locally managed input, DefaultChatTransport — with no append, reload, message.content, or ai/rsc.Coding time
Section titled “Coding time”Build the three client components against the brief and the type contract — invoice-chat.tsx, then invoice-stats-card.tsx, then token-usage-panel.tsx. Lean on TypeScript: if part.output ever reads as unknown inside the tool branch, the generic is missing or the part was destructured too early. The panel and chat are already mounted in the page’s <aside>, so you will see your work the moment it compiles. Reach for the reference below only after you have a version of your own.
Reference solution and walkthrough
Three files, in build order: the chat that owns the conversation, the card it delegates each tool part to, and the panel that polls usage.
invoice-chat.tsx
Section titled “invoice-chat.tsx”The whole client reads top to bottom: the useChat call, the local input, the submit gate, then the render loop.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The typed chat surface. The InvoiceUIMessage generic is what makes part.output narrow in the render below — drop it and every tool branch needs a cast. The endpoint lives on the transport: @ai-sdk/react@2 removed the top-level api option from useChat, so this is the only place the route is named.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};Input is yours to manage in v5 — the SDK stopped auto-managing the field. The <textarea> below is fully controlled off this state. onError turns any failure, quota refusal included, into one sanitized toast.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The in-flight gate, the same one useTransition gave you in the optimistic-mutations work. One request at a time, no empty sends — a second click while the first is streaming returns early instead of firing a duplicate POST /api/chat.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The render walks messages.map then parts.map, switching on part.type. A default: return null lets any unknown or still-transient part degrade quietly rather than crash the tree — the “always have a default case” rule.
'use client';
import { useChat } from '@ai-sdk/react';import { DefaultChatTransport } from 'ai';import { Send } from 'lucide-react';import { type FormEvent, useState } from 'react';import { toast } from 'sonner';import { InvoiceStatsCard } from '@/app/(app)/invoices/invoice-stats-card';import { Button } from '@/components/ui/button';import type { InvoiceUIMessage } from '@/lib/llm/tools';
type InvoiceChatProps = { orgName: string };
export const InvoiceChat = ({ orgName }: InvoiceChatProps) => { const { messages, sendMessage, status } = useChat<InvoiceUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: () => toast.error('Something went wrong. Try again.'), }); const [input, setInput] = useState('');
const inFlight = status === 'streaming' || status === 'submitted';
const onSubmit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (inFlight || input.trim() === '') { return; } sendMessage({ text: input }); setInput(''); };
return ( <div className="flex h-full flex-col gap-3 rounded-lg border p-4"> <div> <h2 className="text-sm font-medium">Ask your invoices</h2> <p className="text-xs text-muted-foreground"> Questions about {orgName}'s invoices. </p> </div>
<div className="flex-1 space-y-3 overflow-y-auto text-sm"> {messages.map((message) => ( <div key={message.id} className="space-y-2"> <span className="text-xs font-medium text-muted-foreground"> {message.role === 'user' ? 'You' : 'Assistant'} </span> {message.parts.map((part, index) => { const key = `${message.id}-${index}`; switch (part.type) { case 'text': return ( <p key={key} className="whitespace-pre-wrap"> {part.text} </p> ); case 'tool-getInvoiceStats': return <InvoiceStatsCard key={key} {...part} />; default: return null; } })} </div> ))} {status === 'submitted' && ( <p className="text-xs text-muted-foreground">Thinking…</p> )} </div>
<form onSubmit={onSubmit} className="flex items-end gap-2"> <textarea value={input} onChange={(event) => setInput(event.target.value)} rows={2} placeholder="How many overdue invoices do we have?" className="flex-1 resize-none rounded-md border bg-background px-2 py-1.5 text-sm" /> <Button type="submit" size="sm" disabled={inFlight}> <Send className="size-4" /> Send </Button> </form> </div> );};The load-bearing line for the type contract: spreading the whole part keeps the discriminated state/input/output union intact so it narrows inside the card. Pick those off as separate props and you throw the narrowing away before the card ever sees it.
Two more details the steps glide past. The Send button is also disabled={inFlight}, so the disabled affordance matches the early-return guard rather than relying on it alone. And orgName arrives as a prop — the page resolves it from the session’s org and passes it down; the chat just renders it in the subheading.
invoice-stats-card.tsx
Section titled “invoice-stats-card.tsx”This is the file where the type narrowing earns its keep. The card takes the whole tool invocation as its prop and switches on part.state. Read the switch one state at a time — the trap is in how you read part, not in the markup.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};The prop is the whole UIToolInvocation<InvoiceTools['getInvoiceStats']> — the same source the chat narrows from, so the card’s prop type can never drift from the message type. Hover it and you see the projected output union.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};Switch on part.state directly, not on a destructured const { state } = part. Destructuring first widens part.output away from the narrowed arm — a real, quiet narrowing trap.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};The two loading states. input-streaming returns null — args are still arriving, nothing useful yet. input-available renders <StatsSkeleton />: a card-shaped skeleton, not a spinner. The shape conveys what is coming.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};Both error paths land on <StatsError />. output-error is the SDK-level failure; the 'error' in part.output guard catches the tool’s own { error } arm before destructuring.
export const InvoiceStatsCard = (part: StatsInvocation) => { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <StatsSkeleton />; case 'output-error': return <StatsError />; case 'output-available': { if ('error' in part.output) { return <StatsError />; }
const { count, totalAmount, byStatus, oldestUnpaidDueDate } = part.output; const filter = part.input.status;
return ( <div className="space-y-3 rounded-lg border p-4"> <h3 className="text-sm font-medium"> Invoice stats{filter ? ` · ${filter}` : ''} </h3> <div className="grid grid-cols-2 gap-3 text-sm"> <div> <p className="text-xs text-muted-foreground">Count</p> <p className="font-medium tabular-nums">{count}</p> </div> <div> <p className="text-xs text-muted-foreground">Total</p> <p className="font-medium tabular-nums"> {formatCurrency(totalAmount)} </p> </div> </div> <dl className="space-y-1 text-xs"> {Object.entries(byStatus).map(([status, n]) => ( <div key={status} className="flex justify-between"> <dt className="capitalize text-muted-foreground">{status}</dt> <dd className="tabular-nums">{n}</dd> </div> ))} </dl> <p className="text-xs text-muted-foreground"> Oldest unpaid due:{' '} <span className="tabular-nums text-foreground"> {formatDueDate(oldestUnpaidDueDate)} </span> </p> </div> ); } default: return null; }};Only here, past both guards, does part.output narrow to the real shape. part.input.status is the optional filter the model passed, surfaced as a title hint.
The rest of the file is the supporting pieces — the prop type, the skeleton, the error message, and the format helpers:
'use client';
import type { UIToolInvocation } from 'ai';import { Temporal } from 'temporal-polyfill';import { Skeleton } from '@/components/ui/skeleton';import type { InvoiceTools } from '@/lib/llm/tools';
type StatsInvocation = UIToolInvocation<InvoiceTools['getInvoiceStats']>;
// The card-shaped loading affordance the tool-parts model provides (107 L2) —// stat-slot placeholders, not a generic loading glyph. Mapped over a stable// string-key tuple so Biome's `noArrayIndexKey` stays happy.const STAT_SLOTS = ['count', 'total', 'oldest'] as const;
const StatsSkeleton = () => ( <div data-testid="invoice-stats-skeleton" className="space-y-3 rounded-lg border p-4" > <Skeleton className="h-4 w-32" /> <div className="grid grid-cols-3 gap-3"> {STAT_SLOTS.map((slot) => ( <Skeleton key={slot} className="h-10 w-full" /> ))} </div> </div>);
const StatsError = () => ( <p className="text-sm text-destructive"> I couldn't load those stats. Try rephrasing. </p>);
14 collapsed lines
const formatCurrency = (amount: number): string => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(amount);
const formatDueDate = (iso: string | null): string => iso === null ? '—' : Temporal.PlainDate.from(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', });
InvoiceStatsCard.Skeleton = StatsSkeleton;The pieces in order:
StatsInvocation = UIToolInvocation<InvoiceTools['getInvoiceStats']>is the prop type — the same source the chat narrows from. Because both the message and the card derive fromInvoiceTools, the card’s prop can never drift from what the chat hands it. Change the tool’soutputSchemaand both ends move together.StatsSkeletonis the card-shaped loading shape, built from the shadcn<Skeleton />primitive: a title bar plus three stat slots. The slots map overSTAT_SLOTS = ['count', 'total', 'oldest'] as const— a stable string-key tuple, not the array index, which keeps Biome’snoArrayIndexKeyrule quiet. A small lint constraint, but it points at a real habit: never key a list on its index when a stable key is available.InvoiceStatsCard.Skeleton = StatsSkeletonexposes the loading shape as a static property on the component, so any surface that wants to show the card’s skeleton without a live tool part can render<InvoiceStatsCard.Skeleton />. The shape is reusable; that is the point of owning it per tool.formatCurrencyis a USDIntl.NumberFormat, andformatDueDateis a TemporalPlainDate.from(iso).toLocaleString(...)with a"—"fallback onnull— the date formatting you built in the internationalization unit, reused.
token-usage-panel.tsx
Section titled “token-usage-panel.tsx”The panel is a single effect that polls, plus some derived display values. No props — it reads the acting user’s usage straight from the endpoint.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};The bar is colored by remaining budget, not used: green over half left, amber in the 10–50% band, red under 10%. A pure helper, so the render stays declarative.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};The one useEffect this feature is allowed — polling an external system is exactly the escape hatch the React-effects discipline reserves an effect for. It opens an AbortController so the in-flight fetch can be cancelled on unmount.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};Poll once immediately, then every ten seconds. The cleanup both aborts the pending fetch and clears the timer — without it the panel leaks a request and an interval on every remount. The abort lands in poll’s catch and is correctly swallowed.
'use client';
import { useEffect, useState } from 'react';
type Usage = { used: number; cap: number; remaining: number };
// Green over half the budget left, amber in the 10–50% band, red under 10%.const barColor = (fraction: number): string => { if (fraction > 0.5) return 'bg-emerald-500'; if (fraction >= 0.1) return 'bg-amber-500'; return 'bg-red-500';};
export const TokenUsagePanel = () => { const [usage, setUsage] = useState<Usage | null>(null);
useEffect(() => { const controller = new AbortController();
const poll = async () => { try { const res = await fetch('/api/usage', { signal: controller.signal }); if (res.ok) setUsage((await res.json()) as Usage); } catch { // Ignore transient/aborted poll failures; the next tick retries. } };
void poll(); const interval = setInterval(() => void poll(), 10_000);
return () => { controller.abort(); clearInterval(interval); }; }, []);
const used = usage?.used ?? 0; const cap = usage?.cap ?? 100_000; const remaining = usage?.remaining ?? cap; const remainingFraction = cap === 0 ? 0 : remaining / cap; const usedPercent = Math.min(100, Math.round((used / cap) * 100));
return ( <div className="space-y-1 rounded-lg border p-3 text-xs"> <div className="flex justify-between text-muted-foreground"> <span>Daily token budget</span> <span className="tabular-nums"> {used.toLocaleString()} / {cap.toLocaleString()} </span> </div> <div className="h-2 w-full overflow-hidden rounded-full bg-muted"> <div className={`h-full transition-all motion-reduce:transition-none ${barColor(remainingFraction)}`} style={{ width: `${usedPercent}%` }} /> </div> <p className="text-muted-foreground tabular-nums"> {remaining.toLocaleString()} remaining </p> </div> );};Derived display values with ?? fallbacks so the panel renders sane numbers before the first response lands. remainingFraction drives barColor; usedPercent is clamped to 100 so the bar never overshoots its track.
Two details the steps don’t call out. poll() only sets state on res.ok, so a dropped poll is silently ignored — it isn’t worth a toast, and the next tick retries. And the bar carries motion-reduce:transition-none, so its width animation respects a reduced-motion preference.
Ten seconds is the simple default for a personal budget that moves slowly. If you needed the spend reflected the instant it changed — a team billing dashboard, say — you would reach for a server-sent usage signal or re-poll off the chat’s own onFinish rather than a fixed interval. Named, not built.
That completes the surface. The project now runs end to end: a typed chat grounded in real invoice aggregates, capped per user per day, refusing gracefully, with every conversation auditable. A few directions this opens, each owned by work you have already done or will do:
- Persisting
messagesso a refresh keeps the conversation — load on mount, write ononFinish— is the obvious next surface; it is left out here on purpose. - The user-facing chat text and the operator-facing audit rows are two different audiences. The error-handling unit’s split between user messages and operator messages, and the observability unit treating
llm_audit_eventsas the operator-truth side, are where that separation gets formalized. - The security-baseline audit later reaches straight for this surface’s
authedRoutewrap and the closure-orgIdrule as the two lines it must not find broken. - Testing this without burning tokens means mocking the model with
MockLanguageModelV2, with the tool’sexecuteunit-testable as a plain function — the testing unit’s approach. - A “you’ve used 90% of your quota” notification through the dispatcher pattern, and reaching for RAG when questions outgrow what an aggregate tool can answer, are the natural next features once this baseline is in place.
The typed tool parts and the four states — input-streaming, input-available, output-available, output-error — your card switches on.
Spreading a tool part into a React component — the same {...part} pattern that feeds InvoiceStatsCard.
The v5 transport-based API: sendMessage, status, onError, and DefaultChatTransport options.
Moment of truth
Section titled “Moment of truth”This project ships no per-lesson test suite — the assessment is the working surface plus your own eyes. Run the full verification to confirm the client typechecks and the app builds:
pnpm verifyThat runs Biome’s CI lint, tsc --noEmit, and next build with SKIP_ENV_VALIDATION=true. The build is the real check that the type contract holds: if part.output widened to unknown anywhere, or you destructured the part before the switch, tsc fails here. Expect green:
$ pnpm verify✓ biome check — no diagnostics✓ tsc --noEmit — no errors▲ next build ✓ Compiled successfully ✓ Generating static pagesDone.Then drive the surface by hand. The live-chat checks make real model calls, so set AI_GATEWAY_API_KEY in .env first; the inspector at /inspector carries the controls each step needs, and “Reset and re-seed” restores the seed between demos.
submitted → streaming → ready, flashes the InvoiceStatsCard.Skeleton while the tool input streams, renders the real card, then an assistant text bubble citing the count.<Spinner finds none in the chat tree — the loading shape is the per-tool skeleton.part.output in the tool-getInvoiceStats branch shows the projected { count, totalAmount, byStatus, oldestUnpaidDueDate } shape, not unknown; the card’s prop is typed from the same UIToolInvocation<InvoiceTools['getInvoiceStats']> source.output-error message and the model’s follow-up asks for a rephrase. Toggle it back off.onError and leaves the input enabled. Reset and re-seed after.POST /api/chat in the network tab.orgId reads org-globex — the scopedInvoices(ctx.orgId).active() inside execute is the structural reason.append(, reload(, message.content, ai/rsc, or streamUI; hits for sendMessage(, message.parts, and DefaultChatTransport; and the only importers of @/lib/llm/ are the two route handlers, with-llm-quota.ts, and invoice-chat.tsx (for the message type) — no Server Component imports the tools or the prompt.With these passing, the project is complete: an ask-your-invoices chat that streams grounded answers under your auth boundary, renders each tool’s result with its own loading shape, caps every user’s daily spend, and refuses every failure mode with a typed shape instead of a thrown 500.