Ask a chat model what the dollar is worth in reais and it will answer, confidently, with a number from its training data. That number is months old. Ask it whether today is a good day to exchange, and it will tell you a story about momentum and inflation that no data backs. And even when the numbers are right, five rates inside a paragraph are hard to compare.
So I built Currency Copilot: a streaming chat for currency questions where the model never states a rate itself. It calls typed tools that fetch European Central Bank rates, and each result renders as its own React component instead of text.
The design goals:
The stack is Next.js 16 with the App Router, React 19, TypeScript, the Vercel AI SDK 6, Zod, Recharts, Drizzle ORM and Neon Postgres.
The model has four tools, each with a Zod input schema, and they run on the server:
The data comes from Frankfurter, a free API over the ECB reference rates, with no key. A tool is a description, a schema and a function:
export const currencyCode = z .string() .regex(/^[A-Za-z]{3}$/) .transform((code) => code.toUpperCase()) .describe('ISO 4217 code, e.g. USD, BRL, EUR')export const getRate = tool({ description: 'The latest exchange rate between two currencies (ECB reference rate, updated each working day). ' + 'The UI shows the result as a rate card, so reply with one short sentence, not a repeat of the numbers.', inputSchema: z.object({ base: currencyCode, quote: currencyCode }), execute: ({ base, quote }) => getLatestRate(base, quote),})
The system prompt says “Use the tools for every rate. Never state a rate from memory.” It also says when to use each tool: “this month” means 30 days of history, and an amount in several currencies is one convert call, not three.
Why the description talks about the UI: without it, the model fetched the rate and then repeated it in a sentence, a bullet list, and sometimes a table, right under the card that already showed it. Telling the model what the user will see is what keeps its reply to one sentence of context.
On the client, useChat streams messages made of typed parts. A tool call arrives as a part named after the tool, and it moves through states: input streaming, input available, output available, or output error. The chat renders each state:
case 'tool-getRate': switch (part.state) { case 'output-available': return <RateCard key={i} {...part.output} /> case 'output-error': return <ToolError key={i} text={part.errorText} /> default: return <RateCardSkeleton key={i} /> }
Because the tools are typed end to end, part.output is the exact return type of the tool on the server, so a renamed field is a compile error in the component, not a blank card in production.
A simple question gets a rate card and one sentence:
A trend question gets a chart, with a tooltip on hover, the latest value labeled at the end of the line, and a table view for screen readers:
Why the publication date is on every card: ECB rates are published once per working day, around 16:00 CET. On a Saturday, “today” is Friday’s rate. Showing the date keeps that honest without a paragraph of disclaimers.
convert takes an amount and up to ten target currencies, and makes a single Frankfurter request for all of them:
Frankfurter has a quirk here. When you ask for several symbols and some are unknown (it covers about 30 currencies, so the Argentine peso isn’t there), it doesn’t fail. It silently drops the unknown ones and returns the rest. It only returns a 404 when none are left. So the tool compares what it asked for with what came back and returns an unavailable list, and the table says which currencies it couldn't convert instead of quietly showing fewer rows.
This is the question people actually want answered, and the one a model is most tempted to make up. So analyzePair splits the job in two.
First, plain TypeScript computes the facts from 90 days of history: the 30-day average, low and high, the 30-day and 90-day change, the trend, and where today’s rate falls among the last 30 days. That percentile decides the verdict:
const percentile30 = Math.round((100 * others.filter((rate) => rate < latest).length) / others.length)const change30Pct = pct(month[0], latest)return { ... trend: Math.abs(change30Pct) < FLAT_PCT ? 'flat' : change30Pct > 0 ? 'rising' : 'falling', stance: percentile30 >= 70 ? 'favorable' : percentile30 <= 30 ? 'unfavorable' : 'neutral',}
Then the model gets those numbers as JSON and writes a headline and two or three reasons, with generateText and Output.object so the result is validated against a Zod schema. The prompt says to use only the given numbers, never forecast, and match the given stance.
If the summary call fails or takes more than 10 seconds, the tool still returns the numbers with no summary, and the card shows the stance and the range without the write-up:
Why not let the model decide: “today’s rate beats 80% of the last 30 days” has one right answer, and a few lines of code get it right every time, for free, and a test can prove it. The model is good at the part code is bad at: turning a percentile and two percentages into a sentence a person reads in two seconds.
The app runs on Gemini’s free tier, and its limits shaped more of the design than I expected.
export function reportTimeouts(stream: ReadableStream<UIMessageChunk>, request: AbortSignal) { return stream.pipeThrough( new TransformStream<UIMessageChunk, UIMessageChunk>({ transform(chunk, controller) { controller.enqueue(chunk.type === 'abort' && !request.aborted ? { type: 'error', errorText: TIMEOUT_MESSAGE } : chunk) }, }), )}
While I was taking the screenshots for this article, the free tier was overloaded: the first token took 40 seconds and the other Flash Lite model returned 503. The chat showed the timeout message instead of hanging, which was a better test of this code than any I wrote.
Every visit to / redirects to a new /chat/[id]. The page loads the saved messages on the server, and the chat route saves the whole conversation when an answer finishes:
const stream = result.toUIMessageStream({ originalMessages: messages, // Server-side ids, so a saved answer keeps the id the browser saw generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }), onFinish: async ({ messages }) => { await store?.save(chatId, owner, messages) },})
A few details made this work properly:
The database is Neon Postgres with Drizzle ORM: two tables, chats and messages, with the message parts stored as JSONB. The app uses Neon’s pooled connection string and migrations use the direct one. A new chat lists the previous ones under Recent chats:
Every test runs without an API key, network access or a database account.
GitHub Actions runs lint, typecheck and both suites on every push, in about 80 seconds:
Why a scripted model and not the real one in tests: the tests check my code, not Gemini. A model that calls convert with 100 USD every time makes a failure mean a bug, while a real model on a free tier fails for reasons that have nothing to do with the code, as the screenshots for this article proved.
I built this with Claude Code as a pair programmer. It wrote most of the code, the tests and the first drafts of the docs. My part was the decisions and the checking: which questions deserve a component instead of text, keeping the verdict out of the model, which free model to default to, and testing every tool against the real model in the browser. Several fixes came from those manual checks, like the silent timeout and the duplicate numbers under the cards.
The rates are ECB reference rates, published once a day, not live market quotes, and banks and exchange services add their own spread on top. The summary card describes the last 30 and 90 days; it doesn’t predict anything, and it says so. Chats belong to a browser cookie, so clearing cookies loses access to them.
Next up:
The code is on GitHub: github.com/Eric-Souza/currency-copilot. With a free Gemini key, npm install and npm run dev get you to the screens above, and npm test runs without any key at all.
Which would you rather get from a chat assistant: a well-written paragraph, or a chart? Leave a comment.
I built a currency chat where the answers are charts, not paragraphs was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.