Build an AI Chat Interface Over Your PayPal Transactions with Claude
A step-by-step tutorial: tool definitions, tool execution, SSE streaming, and the agentic loop
This tutorial walks through the backend implementation of a chat interface that lets users ask questions about their PayPal transaction history in plain English. A user types "who were my top customers last quarter?" — Claude figures out what to query, runs the queries, and streams back an answer.
Here's what we're building toward:
User: How did revenue compare month over month for the last 6 months?
Claude:Here's the breakdown for Jan–Jun 2026: January $12,450, February $9,820 (down 21%), March $14,200 (up 45%)...
This tutorial covers tool definitions, tool execution, and the streaming agentic loop. It doesn't cover auth, the database schema, or syncing PayPal data — those are separate topics. Assumes you have Node/TypeScript, a PostgreSQL database with transaction rows, and an Anthropic API key.
Step 1: Define Your Tools
Tools are JSON schemas that you pass to Claude alongside the user's message. They tell Claude what functions it can call and when to call them. Claude reads the description fields to decide which tool to invoke — so write them for Claude, not for humans.
Here's the full definition for get_transaction_summary, the workhorse tool:
import Anthropic from '@anthropic-ai/sdk';
const TOOLS: Anthropic.Tool[] = [
{
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',
},
},
},
},
{
name: 'search_transactions',
description:
'Search and filter PayPal transactions. Use this to find specific transactions by date, amount, counterparty, or status.',
input_schema: {
type: 'object',
properties: {
startDate: { type: 'string', description: 'ISO 8601 start date (inclusive)' },
endDate: { type: 'string', description: 'ISO 8601 end date (inclusive)' },
minAmount: { type: 'number', description: 'Minimum transaction amount' },
maxAmount: { type: 'number', description: 'Maximum transaction amount' },
counterparty: { type: 'string', description: 'Partial match on counterparty name or email' },
status: { type: 'string', description: 'Transaction status e.g. COMPLETED, PENDING' },
limit: { type: 'number', description: 'Max results to return (default 20, max 50)' },
},
},
},
{
name: 'get_transaction_items',
description: 'Get the line items (products/services) for a specific transaction.',
input_schema: {
type: 'object',
properties: {
transactionId: {
type: 'string',
description: 'The PayPal transaction ID (not the internal DB id)',
},
},
required: ['transactionId'],
},
},
];Three tools covers the vast majority of questions users ask. More tools means more decision surface and slower responses. Keep the set minimal.
Step 2: Implement Tool Execution
Tool execution is your code — your queries, your access control. Claude tells you which tool to call and what arguments to pass; you run the query and return the result. The credentialId(which account's data to query) always comes from the authenticated session, never from Claude.
async function executeTool(
name: string,
input: Record<string, unknown>,
credentialId: string,
): Promise<unknown> {
if (name === 'search_transactions') {
const { startDate, endDate, minAmount, maxAmount, status, counterparty, limit } =
input as {
startDate?: string;
endDate?: string;
minAmount?: number;
maxAmount?: number;
status?: string;
counterparty?: string;
limit?: number;
};
const rows = await db.transaction.findMany({
where: {
credentialId,
AND: [PARENT_ONLY],
...(startDate || endDate ? {
initiatedAt: {
gte: startDate ? new Date(startDate) : undefined,
lte: endDate ? new Date(endDate) : undefined,
},
} : {}),
...(minAmount !== undefined || maxAmount !== undefined
? { amount: { gte: minAmount, lte: maxAmount } }
: {}),
...(status ? { status } : {}),
...(counterparty ? {
OR: [
{ counterpartyName: { contains: counterparty, mode: 'insensitive' } },
{ counterpartyEmail: { contains: counterparty, mode: 'insensitive' } },
],
} : {}),
},
orderBy: { initiatedAt: 'desc' },
take: Math.min(limit ?? 20, 50),
});
return rows;
}
// ... get_transaction_summary, get_transaction_items
}Notice PARENT_ONLYspread into every query's where clause. PayPal stores internal accounting entries alongside real transactions — they have a referenceIdType of 'TXN' and represent steps within a single payment, not separate payments. If you include them, all your metrics are wrong: counts double, revenue inflates, customer lists gain phantom entries.
const PARENT_ONLY = {
OR: [
{ referenceIdType: { not: 'TXN' } },
{ referenceIdType: null },
],
};This filter belongs in the query, not in a system prompt. A prompt instruction is a suggestion; a SQL constraint is a guarantee.
Step 3: The Streaming Endpoint
This is the main event. The POST handler for sending a message sets up SSE, calls Anthropic, and runs the agentic loop.
Flush headers before the first Anthropic call
app.post('/conversations/:id/messages', async (req, res) => {
// ... validate, load conversation, save user message ...
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // prevents nginx from buffering SSE
});
res.flushHeaders(); // ← must happen before anthropic.messages.stream()
// Now start the Anthropic call
});flushHeaders()must be called before the Anthropic stream starts. The browser's connection must be open before the first token arrives. If you flush after the stream begins, the client sits waiting for the full response — no streaming, just a long hang followed by everything at once.
The agentic loop
function sseWrite(res: Response, event: Record<string, unknown>) {
res.write('data: ' + JSON.stringify(event) + '\n\n');
}
let currentMessages = history; // full conversation history including new user message
while (true) {
const stream = anthropic.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: `You are a financial assistant helping the user understand their PayPal transaction history.
Use the provided tools to query their data. Today is ${new Date().toISOString().slice(0, 10)}.
Always include the currency code with amounts. Use human-readable dates in responses.`,
tools: TOOLS,
messages: currentMessages,
});
// Stream text tokens to the client as they arrive
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: Anthropic.ToolResultBlockParam[] = [];
for (const block of message.content) {
if (block.type !== 'tool_use') continue;
sseWrite(res, { type: 'tool', name: block.name }); // tell client a tool is running
const result = await executeTool(block.name, block.input as Record<string, unknown>, credentialId);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
});
}
// Append Claude's tool call + your results, then loop
currentMessages = [
...currentMessages,
{ role: 'assistant' as const, content: message.content },
{ role: 'user' as const, content: toolResults },
];
continue;
}
break; // unexpected stop_reason
}
sseWrite(res, { type: 'done' });
res.end();The loop is what makes multi-step reasoning possible. Without it, Claude gets one round of tool calls. With it, Claude can call get_transaction_summary, read the totals, decide it needs a month-by-month breakdown, call it again with groupBy: 'month', and then write the final answer — all in a single user turn.
Step 4: Consuming the Stream on the Frontend
Use fetch with response.body.getReader(), not EventSource. EventSourceonly supports GET requests and doesn't let you set headers or send a body.
const res = await fetch(`/api/conversations/${convId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content: userMessage }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep incomplete line in buffer
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6).trim());
if (event.type === 'text') {
accumulated += event.delta;
setStreamingContent(accumulated); // render incrementally
} else if (event.type === 'tool') {
setStatus(`Running: ${event.name}...`);
} else if (event.type === 'done') {
setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.message);
}
}
}The buffer handling (lines.pop()) is important. Network chunks don't respect SSE boundaries — a chunk can arrive mid-event. You hold the incomplete line in the buffer and process it on the next chunk.
End-to-End Flow
- User submits a message → POST
/conversations/:id/messages - Server saves the user message to DB, flushes SSE headers
- Agentic loop starts — Claude streams tokens, calls tools as needed
- Each token →
{ type: 'text', delta }SSE event → client appends to UI - Each tool call → server executes query, feeds results back to Claude, continues loop
end_turn→ server saves assistant response, sends{ type: 'done' }- Client finalizes the message