Building a Conversational Analytics Product with Claude's Tool Use
How I turned raw PayPal transaction data into a natural-language financial analyst
Most PayPal users navigate their business through a dashboard full of tables and filters. It works, but it's query-hostile — you can't ask it "which customers drove most of my growth last quarter?" and get a straight answer. So instead of building another dashboard, I gave users a financial analyst they can talk to. You type a question in plain English; it figures out what to query, runs the queries, and writes you an answer. This post is about the specific Claude feature that makes this possible: tool use.
What Is Tool Use?
Tool use (also called function calling) is how you give Claude the ability to take actions — in this case, query a database.
The pattern has four steps:
- You define a set of tools as JSON schemas and pass them with your API request
- Claude reads the user's message and decides which tool to call, and with what arguments
- Your code executes the tool and returns the result
- Claude synthesizes a final response — and can chain multiple tool calls before it does
Here's what a tool definition looks like:
{
name: 'get_transaction_summary',
description:
'Aggregate transaction data — totals, counts, and optional grouping. Use this for "how much", "how many" type questions.',
input_schema: {
type: 'object',
properties: {
startDate: { type: 'string', description: 'ISO 8601 start date (inclusive)' },
endDate: { type: 'string', description: 'ISO 8601 end date (inclusive)' },
groupBy: {
type: 'string',
enum: ['month', 'counterparty', 'status'],
description: 'Optional grouping dimension',
},
},
},
}Claude reads the description fields to decide when and how to call each tool. Write them for Claude, not humans — say when to use the tool, not just what it does.
Security: Claude Never Touches the Database
This is worth stating explicitly, because it's one of the most important architectural decisions.
Claude has no database credentials. It cannot write SQL. It can only call the tools you define.
Every piece of data Claude sees flows through a controlled function call. Your code executes the query, applies authorization (the credentialId— which account's data to query — is always scoped to the authenticated user and never comes from Claude), and returns only what you choose to return. If Claude hallucinates a tool name or passes unexpected arguments, the executor returns an error or ignores the unknown fields.
The alternative is giving Claude a database connection string and letting it write its own SQL. That's powerful, but it gives Claude unbounded read access and opens injection risks. The tool-based approach trades some flexibility for a much tighter security boundary: the tools are the API surface, and you own that surface entirely.
The Three Tools
Three tools cover the vast majority of analytical questions users ask.
search_transactions— filter by date range, amount range, counterparty name or email, or status. Returns up to 50 rows. Used for questions like "show me all payments from Emma Wilson" or "what transactions happened in December over $50?"
get_transaction_summary — aggregate totals, counts, and averages with optional groupBy: month, counterparty, or status. This is the workhorse. Most analytical questions resolve to a summary: "how much did I earn last quarter?" or "who are my top customers by revenue?"
get_transaction_items — fetch the line items for a specific PayPal transaction ID. Used when Claude needs to inspect what was inside a multi-item order.
Three tools is enough. More tools means more decision surface and slower responses. The minimum viable tool set is the right tool set.
The PARENT_ONLY Gotcha
PayPal stores child transactions (referenceIdType = 'TXN') as internal accounting entries for a single real payment. Including them inflates counts and sums by 2–3×. Claude was confidently reporting wrong totals until I found this and baked the filter into every query:
const PARENT_ONLY = {
OR: [
{ referenceIdType: { not: 'TXN' } },
{ referenceIdType: null },
],
};The lesson: domain knowledge belongs in the filter, not the prompt. Trying to explain PayPal's internal accounting to Claude in a system prompt is fragile — a SQL constraint is reliable.
The Agentic Loop
The part most tutorials skip is the loop. A single request-response gets you a single tool call. To enable multi-step reasoning — Claude calls a tool, sees the results, decides it needs more data, calls another tool — you need to keep the conversation going until Claude says it's done:
while (true) {
const stream = anthropic.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: systemPrompt,
tools: TOOLS,
messages: currentMessages,
});
stream.on('text', (text) => {
sseWrite(res, { type: 'text', delta: text });
});
const message = await stream.finalMessage();
if (message.stop_reason === 'end_turn') break;
if (message.stop_reason === 'tool_use') {
const toolResults = [];
for (const block of message.content) {
if (block.type !== 'tool_use') continue;
const result = await executeTool(block.name, block.input, credentialId);
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result) });
}
currentMessages = [
...currentMessages,
{ role: 'assistant', content: message.content },
{ role: 'user', content: toolResults },
];
continue;
}
break;
}When stop_reason is tool_use, you execute the tools and feed the results back as a new user turn. When it's end_turn, Claude is done. That's the whole loop.
Notice the streaming: tokens are sent to the client via SSE as they arrive, not buffered until the end. This matters because multi-step queries can take 10–15 seconds. Users need to see something happening.
One critical SSE detail: flushHeaders() must be called before anthropic.messages.stream(). The browser's EventSource connection must be open before the first token arrives — if you flush after the Anthropic call starts, the client hangs until the entire response is ready.
What I Learned
Tool design is product design. The tools you give Claude define what questions are answerable. A get_transaction_summary with a groupBydimension means Claude can answer "break down revenue by customer" without any special casing. Spend time on the schemas and descriptions — they're the interface between Claude's reasoning and your data.
Domain knowledge belongs in the filter, not the prompt. The PayPal PARENT_ONLY filter is the clearest example: a constraint in SQL is enforced on every query, always. An instruction in a system prompt is a suggestion Claude might not follow consistently, especially across long multi-turn conversations.
Flush headers before the first API call.It's a one-liner, but forgetting it costs you a confusing debugging session. Always call flushHeaders() before anthropic.messages.stream().