Finn: a chat-driven budget ledger with an AI agent (technical case study)
Stack: n8n Cloud, Discord REST API, Google Sheets API, n8n Data Tables, Claude (via n8n AI Agent), JavaScript
Scope: 32 nodes, 2 triggers, 2 Code nodes, 1 AI agent with 7 tools
OVERVIEW
Finn turns Discord messages into rows in a Google Sheets budget and routes everything that isn't an expense to an AI agent with read access to that budget. The design goal was reliability first: a message must be handled exactly once, the AI must never be able to corrupt financial data, and an AI outage must never block logging.
WHAT IT DOES
Fast logging: short codes route each expense to a fund. Amount, fund and note can come in any order.
Asks instead of guessing: if the fund is missing, Finn asks "Which fund? h or s", waits, then logs the original expense. One reminder if I don't answer.
Budget alerts: a warning at 80% of a fund's monthly limit, and an alert when it's passed.
AI coach with real numbers: tells me what's left, what's already committed, and what a purchase would do to my savings goal. It suggests a 24 hour pause before big decisions and logs what I decided.
Lasting memory: recent conversation plus long-term notes saved to their own tab.
Weekly summary: spending per fund, every Sunday evening.
ARCHITECTURE
The workflow has four lanes:
A. Ingest (scheduled)
Schedule Trigger > Discord: get last 50 channel messages > Data Table: get processed messages > Sheets: read household tab > Sheets: read savings tab > Code: parse and classify
B. Chat
Sheets: read notes > Code: build context > AI Agent > Discord: post reply > Data Table: mark handled
C. Ledger
IF logged expense > Sheets: append row > Discord: confirm > Data Table: mark logged
ELSE > Discord: ask for fund or send reminder > Data Table: store as pending
D. Reporting (weekly)
Schedule Trigger > read both tabs > Code: aggregate > Discord: post summary
INGESTION WITHOUT A TRIGGER
n8n Cloud has no native Discord message trigger, since that requires a persistent gateway connection. I used polling instead, with two constraints: stay inside the plan's monthly execution limit, and never double-process.
Cron expression */15 7-22 * * * in Asia/Manila, about 1,950 executions a month.
Each run fetches the latest 50 messages, drops bot authors, and drops anything before a fixed cutoff timestamp so history from before launch is never replayed.
Messages sent outside the polling window are picked up on the first run of the morning, because selection is by "not yet seen", not by time window.
IDEMPOTENCY AND STATE
Polling means every run sees messages it has already handled. State lives in an n8n Data Table keyed on the Discord message ID:
status is one of logged, pending, chat or ignored.
The parser builds a "seen" set from message_id and answer_id, so both the original message and the reply that resolved it are excluded on later runs.
Every write is an upsert matched on message_id, so a retried run can't create a duplicate record.
THE PARSER
A single Code node classifies each unseen message. It is deterministic by design. No LLM is involved in anything that writes to the ledger.
Tokenize on whitespace. An amount matches ^₱?\d[\d,]*(.\d+)?$.
Fund codes map through a lookup: h and hh to household, s and hysa to savings.
Classification:
first token is not an amount: chat
exactly one amount, fund present: expense, logged immediately
exactly one amount, no fund: pending, bot asks "Which fund? h or s"
more than one amount: ambiguous, bot asks the user to resend
a single token that is a fund code: answer, matched to the oldest pending expense sent before it
Whatever is left after removing the amount and fund tokens becomes the note, so word order doesn't matter.
Pending expenses carry asked_at. If one is still open 10 minutes later, the next run sends one reminder and sets reminded so it never nags twice.
BUDGET MATH
Totals are computed in code, not by the model.
Sheets returns dates as serial numbers (UNFORMATTED_VALUE), which the parser converts to a yyyy-MM month key in UTC to avoid timezone drift.
Month totals per fund come from the sheet, plus an in-memory accumulator for expenses logged earlier in the same run. Without that, three expenses in one poll would each report the same "remaining" figure.
The expense's month is taken from the message timestamp converted to Asia/Manila, so a late-night entry lands in the right day and month.
Replies include the remaining balance, a warning at 80% of the limit, and an alert once the limit is passed.
THE AGENT
Chat messages go to an n8n AI Agent node running Claude.
Permission model
Three tools read financial tabs. The only writable targets are two tabs the agent owns: a notes tab and a decision log. There is no tool that can write to a budget tab, so the constraint is structural, not just a prompt instruction.
Tool arguments
Write tools use $fromAI() for the fields the model should fill (note text, amount, decision) while the date is set by an n8n expression, so the model can't invent timestamps.
Memory, two layers
Short term: window buffer of 30 messages, session key tied to the channel.
Long term: the notes tab. It is read by a regular Sheets node before the agent runs and injected into the system message, so the agent starts every turn with its saved context without spending a tool call.
Prompt design
The system message separates behavior (ask one question at a time, quote only figures from the sheet, suggest a 24 hour pause before large purchases) from a reference section describing each tool and the sheet's layout. The main budget tab has months laid out side by side in repeating 8-column blocks, so the prompt explains how to locate a month's block and which row labels are savings and which are loan payments.
FAILURE HANDLING
The agent node is set to continue on error. If the model call fails, the reply node posts a fallback message and the message is still marked handled, so it isn't retried every 15 minutes.
This matters because both branches run in one execution. Before this change, a model failure stopped the run before the ledger branch, which meant an AI outage blocked expense logging.
The notes read is also set to continue on error, so a missing notes tab degrades to "no notes yet" instead of failing the chat.
Agent iterations are capped at 12 to bound cost on a runaway tool loop.
TRADE-OFFS
Latency: up to 15 minutes between message and reply. Acceptable for logging, weak for conversation. A platform with a webhook trigger (Telegram, Slack) would make replies instant and cut executions to one per message.
Polling depth: 50 messages per run. More than 50 messages between polls would drop the oldest. Fine for one user, not for a busy channel.
The side-by-side budget tab is passed to the model nearly raw. A pre-processing step that normalizes it into one row per transaction would reduce tokens and misreads.
Limits are constants in code. Moving them to a config tab would let a non-technical user change them.
WHAT I'D BUILD NEXT
Webhook-triggered version on Telegram or Slack.
Normalizer for the monthly tab, exposed to the agent as a single "get month summary" tool.
Config tab for funds, codes and limits, so adding a fund needs no code change.
Edit and undo commands for logged expenses.
Like this project
Posted Oct 11, 2026
Developed Finn, an AI-enhanced budgeting tool using n8n, Discord, and Google Sheets.