Building Lukre: Where Conversational AI Meets Deterministic Software
From a WhatsApp message to a financial record: the boundaries, contracts and application logic behind Lukre.

A message such as “spent 85 at the supermarket” gives the person who sends it very little to do. For the application, it opens a different set of questions: who sent it, which household it belongs to, what amount was meant, whether this exact message was already applied, and what the reply should say.
Lukre lives in that gap. On the outside it’s a WhatsApp conversation. Behind it there are financial records, a domain model and rules that have to hold whether the message was typed, spoken or photographed. This article follows that one message through the system, and stops at each boundary to look at what decides what.
Two systems, one conversation
Lukre is two pieces of software with a contract between them.
The conversation layer is Hermes, an open-source agent runtime, used without changes to its core. It holds the WhatsApp session through Baileys (an unofficial WhatsApp Web client, not Meta’s Business API), runs the model that reads each message, and decides which tool to call. Each household has its own agent profile. A small plugin of mine, lukre-gate, sits in front of it.
The finance service is a Python 3.12 application built on FastAPI. It’s the only source of truth for money. The agent reaches it through one door: a Model Context Protocol (MCP) server that publishes the service’s operations as tools. The same use cases are also available over REST for a server-rendered dashboard. Records live in PostgreSQL, through SQLAlchemy and Alembic migrations.

The split is the main architectural decision, and its value is easy to state: the model can be swapped, re-prompted or wrong, and the records still answer to code that can be tested. Its cost is just as concrete: every capability needs a tool, a contract and a test, and the agent can only be as good as the tool catalog in front of it.
Put another way, there are three kinds of code in this path. The model is probabilistic: it understands intent, picks a tool, reads receipt images and words replies. MCP is the contract: it publishes capabilities, checks the shape of each call and carries inputs and results across. The application is deterministic: it derives the trusted context, authorizes, reads typed amounts, applies the rules, claims each message once, persists and computes every total. The lines aren’t perfectly clean, and the sections below show where they bend.
Following one message
The gate
Before the agent sees anything, lukre-gate runs as a pre-dispatch hook. It ignores groups and broadcasts, normalizes the sender’s WhatsApp identifier to a phone number, and asks the finance service one question: does this number belong to an active household? If not, the message is skipped. If something fails along the way, the answer is also “skip”, not “let it through”.
That’s where the adapter’s job ends. It doesn’t parse amounts or pick categories. It decides whether the conversation should happen at all.
The model chooses a tool
The agent reads the message and, for a plain expense, calls the create_transaction tool. This is its handler on the finance side, reduced to the text path:
@tool("create_transaction")
async def create_transaction(
ctx: Context,
*,
sender_number: Sender = "auto",
channel_message_id: str,
message: str | None = None,
) -> str:
async def run() -> Any:
async with _scope(ctx).transactions() as service:
result = await service.create_transaction(
sender_number, message, channel_message_id=channel_message_id
)
return result.as_json()
return await guard("create_transaction", ctx, run)There’s no logic in it, on purpose. _scope(ctx) builds the services for the household behind the request, the use case does the work, and guard turns any typed refusal into a stable error code. The handler returns a JSON string on success and on failure and never raises, because an exception at this boundary would reach the member as silence.
The detail worth noticing is the message argument. The model doesn’t hand over an amount. It passes the text along, and a deterministic parser in the domain reads it, with vocabularies for Portuguese, English and Spanish. What the tool receives and what the domain works with are different types:
message text → MCP tool input → application service → read_message() → CapturedEntry → checks → transaction row@dataclass(frozen=True, slots=True)
class CapturedEntry:
"""One complete financial fact read from a message."""
kind: EntryKind
amount: Decimal
currency: str
description: str
category_slug: str
transaction_date: date
@dataclass(frozen=True, slots=True)
class MessageReading:
"""The reader's verdict. ``entries`` is non-empty only when status is ``READ``."""
status: ReadingStatus
entries: tuple[CapturedEntry, ...] = ()Both are reduced from the domain’s capture module. The model never builds a CapturedEntry; the parser does, or it returns a reading that is a question instead. The model decides which operation is being asked for. The domain decides what the numbers are.
Two things are deliberately missing from the tool’s signature. There is no household argument: the household comes from the credential of the agent profile that made the call, resolved before any handler runs, and a handler only reads it from the request, never from something the model could fill in. And sender_number defaults to "auto" because, on WhatsApp turns, the plugin overwrites it with the number the gate already verified before the call reaches the finance service.
MCP as the boundary
The agent never imports finance code, never sees a class or a table, and never writes SQL. What it sees is a curated set of tools, grouped around a few capabilities: transactions, budgets, receipts and documents, reminders, and reports. Each tool has a name, a description written in the household’s language, and an input schema derived from the typed signature of its handler, which the MCP layer validates before the handler runs. The household’s agent profile lists the tools it may use, so the catalog the model can choose from is explicit.
That gives the conversation layer a vocabulary of operations, not authority over the data. MCP doesn’t decide which household a call belongs to, whether the sender may act, whether the amount makes sense, whether this message was already applied, or what the database accepts. All of that is resolved behind the boundary, in the same services the dashboard uses, so every entry point gets the same guarantees.
The boundary pays for itself in a few concrete ways. The agent runtime is decoupled from the internals of the service: classes and queries can change without touching a prompt. The contract can be tested without a model: integration tests call every registered tool the way the MCP runtime does and check that each one returns a JSON string and never raises, that typed errors become stable codes, that a call without a valid credential never reaches a service, that money crosses as a string and comes back as the same Decimal, and that the tool list in the agent profile matches the server exactly. And a new capability is a new tool with its own contract, not a new paragraph of financial rules in a prompt.
It also has costs. Every capability needs a contract, a description and tests. Descriptions are part of the interface, because they steer which tool the model picks. A larger catalog means a larger decision space for the agent, and every change to a tool has to stay in step with the profile that lists it and with the behavior the model learned from the old description.
Inside the service
TransactionService.create_transaction does its work in a fixed order, inside one database transaction:
- Authorize. The sender must be an active member of the token’s household. A refusal is audited.
- Claim the message. The message id goes into an idempotency table with a unique constraint on household, channel and message id. If it’s already there, nothing new is recorded.
- Read. The parser turns the text into a
CapturedEntry, or into a question. - Check the amount. Zero and negative amounts are refused. Anything above a sanity ceiling isn’t rejected; it turns into a confirmation question, because a misplaced digit is more likely than a six-figure grocery run.
- Persist and confirm. The record is written and the domain builds the confirmation sentence from the stored values.

The reply the member sees starts in code too. A unit test pins the exact English sentence for an English household (“Recorded: R$ 120,00 on groceries yesterday.”), and the agent is instructed to word replies from tool results without recomputing them.
Contracts, domain and money
A schema answering “yes” only proves that the shape was right. For a financial record there are at least five separate questions, and they live in different places:
| Question | Where it’s answered |
|---|---|
| Does the input have the expected shape? | Tool signatures and Pydantic models at the API boundary |
| Does the information make sense and is it complete? | The domain parser: one amount, a clear direction, a date that isn’t ambiguous |
| May this person do this here? | Authorization against the household behind the token |
| Do the rules allow the change? | Domain checks, plus database constraints such as amount > 0 |
| Was it persisted, or is it still pending? | The capture result: recorded, question asked, or refused |
Money is a Decimal everywhere, quantized to two places with half-up rounding, stored as NUMERIC(14,2) and sent across the tool boundary as a string. A float is not accepted by the helpers that sum amounts; it raises a TypeError instead of being converted quietly.
That precision has a limit worth saying out loud. Exact arithmetic on a wrongly read amount is still wrong. The parser reduces that risk by asking when a message holds two amounts or no clear direction, but it can’t know that “85” was supposed to be “58”.
Lukre also records rather than holds money. A recorded expense is what someone said they spent. A budget is a limit the household set. Neither is a bank balance, and the product doesn’t connect to banks or move money.
Who is asking
Identity is split across two facts that come from two different places.
The household comes from the credential of the agent profile, checked before any tool runs; an unknown or revoked credential, or a suspended household, never reaches a service. No tool takes a household id as an argument, so there’s nothing for a prompt to talk the model into changing.
The member comes from the sender. On WhatsApp turns, the plugin pins sender_number to the number it verified, and the service checks that this number is an active member of the token’s household. An integration test covers the case that matters most: a real member of household B, calling with household A’s token, is refused.
Below the application there’s a second layer. Every tenant table carries a household_id; the ORM filters every query by it and stamps it on new rows, and PostgreSQL row-level security is enabled and forced on those tables, scoped per transaction. A session without a household reads nothing.
Isolation between households is not privacy between members of the same household. Receipts and extracted documents are visible to every active member of that household, and the public FAQ says so. Reminders are the exception in the current model: each person sees the ones they created, the ones addressed to them, and the ones addressed to everyone.
Receipts, voice notes and uncertainty
Voice notes are transcribed in the conversation layer. The transcript reaches the same create_transaction tool with origin="audio" and a transcription status, and then follows the text path. An empty or failed transcription produces a request to resend, not a guess.
Receipts are where a model does read numbers, so the boundary moves. For Brazilian electronic receipts, the QR code is decoded in code first. The image or PDF text then goes to a multimodal model, called with a JSON response format at temperature zero, and its output is validated against a schema and treated as a candidate. Two checks decide whether it can become a fact. This is the arithmetic one, from the receipts module of the domain:
def reconcile(
items: Sequence[ExtractedItem],
*,
discount: Decimal = Decimal(0),
stated_total: Decimal | None,
tolerance: Decimal = RECONCILIATION_TOLERANCE,
) -> Reconciliation:
"""Check the arithmetic. Exact decimals throughout, so R$ 0,02 means R$ 0,02."""
items_total = sum_amounts([item.total_price for item in items])
computed = quantize_money(items_total - discount)
if stated_total is None:
return Reconciliation(
items_total=items_total,
discount=discount,
stated_total=None,
difference=Decimal("0.00"),
balanced=False,
)
difference = quantize_money(abs(computed - stated_total))
return Reconciliation(
items_total=items_total,
discount=discount,
stated_total=stated_total,
difference=difference,
balanced=difference <= tolerance,
)ExtractedItem is the model’s proposed receipt line, a plain data class. A receipt without a stated total never balances, and the validation step turns that case into its own question before the difference is ever read.
The items, minus discounts, must match the stated total within two cents. The total, the merchant and the date must each come with a confidence of at least 0.80, and a missing confidence counts as low. Failing either check marks the purchase for review and asks the member one specific question. The module’s own docstring states the rule I care most about here: nothing adjusts a figure to make it balance.
A receipt is also where double counting would sneak in, so the model of a purchase is strict. Ingesting a receipt creates one purchase with its line items and exactly one expense for the total. Items are never expenses of their own. When someone types “spent 85 at the supermarket” and sends the receipt later, the service offers to attach the receipt to the existing expense instead of creating a second one. Integration tests check that totals don’t change after attaching, and that two genuinely identical payments still stay separate.

“AI interprets, the domain decides” is close, but not exact, and the difference matters. A closer version: the model proposes operations, and trusted context plus deterministic code decide what can become state. The agent does call tools that change state: a clear expense is recorded right away, because asking “do you want me to record this?” after every coffee would defeat the product. What the agent can’t do is skip the rules on the way. Destructive or configuration tools, such as deleting a transaction or changing a budget, return confirmation_required first. The confirmation is single-use, expires, and is bound to the person, the tool and a hash of the exact parameters.
Reminders, and work that happens later
Reminders are the clearest asynchronous path. There’s no message broker: background loops run inside the service, and PostgreSQL coordinates them with advisory locks and FOR UPDATE SKIP LOCKED, so two workers never pick the same due reminder.
Each reminder has a recipient that must still be an eligible member, and a schedule computed in the household’s own timezone. When it’s due, the service writes a message to an outbox with a deterministic deduplication key. A worker in the plugin claims outbox rows with a short lease and sends them, backing off between attempts; after the last one, the row is marked failed and an operator alert is raised. A reminder that goes out more than five minutes late carries a note saying so, and one that’s an hour late is marked missed instead of arriving out of context.
When things go wrong
Three failures say more about the design than the happy path does.
The same event arrives twice. The idempotency claim and the records commit in the same transaction, so a repeated message id records nothing new. The limit: this protects against a repeated id, not against the same expense told twice in different words. That one is a product question, not a constraint.
A dependency is unavailable. A refusal from the domain commits, while a transient error rolls back and releases the message claim. If the receipt model is down or times out, the receipt is reported as unreadable and has to be sent again, rather than getting an invented reading. The cost is a retry that the person has to do.
The record is saved but the reply doesn’t arrive. The record stands, and a retry of the same message won’t create a second one. Messages the service sends from its outbox, such as reminders, are delivered at least once, so a lost acknowledgement can produce a duplicate message, never a duplicate record. Sending is not the same as delivering, and the system doesn’t claim end-to-end exactly-once.
Organization, tests and trade-offs
The finance package is split by responsibility: domain holds pure rules (money, periods, parsing, receipt validation, schedules), services holds use cases, repositories holds SQLAlchemy models and queries, api holds the MCP tools, REST and wiring, and extractors holds the model calls. The domain imports no web framework and no database code. The separation isn’t perfect: the domain shares a few plain data classes with the extractor package, and services talk to SQLAlchemy directly. I’d rather name that than draw a cleaner diagram than the code.
Model providers live behind the extractor module, configured in one wiring function, so the rest of the service never builds a prompt or parses a model response. Swapping a provider is a contained change. It isn’t free: a different model can read receipts differently, and only the confidence and arithmetic gates stand between that and a record.
The test suite has unit tests for the domain, integration tests that run against a real PostgreSQL database, and end-to-end tests of whole flows. The MCP contract tests described above sit in the integration layer. Model calls in all of these tests are fakes that replay recorded extractions, so they prove how the application reacts to a reading, not how well a model reads. There’s no evaluation suite for model behavior yet. Here is the kind of test I find most useful in a financial domain, small enough to read in one breath. It’s adapted from the money tests: the real ones are methods with requirement ids in their names.
def test_summing_decimal_amounts_is_exact() -> None:
total = sum_amounts([Decimal("0.07")] * 200)
assert total == Decimal("14.00")
assert str(total) == "14.00"
def test_float_amounts_are_rejected() -> None:
with pytest.raises(TypeError):
sum_amounts([Decimal("0.07"), 0.07])
def test_negative_amount_is_rejected() -> None:
with pytest.raises(ValidationFailed) as raised:
check_amount(Decimal("-5"))
assert raised.value.code == "amount_not_positive"Three decisions, and what each one leaves open:
- A deterministic parser reads typed amounts. It helps: exact, testable numbers in three languages, and the same input always gives the same entry. It doesn’t solve phrasing outside its vocabulary, which becomes a question instead of a guess.
- One token per household, plus database row security. It helps: the model never chooses the tenant, and a bug in one query still can’t read another household. It doesn’t solve privacy between members of the same household, which is a product rule, not an isolation guarantee.
- Write tools without confirmation for clear expenses. It helps: recording feels like sending a message. It doesn’t solve a misunderstood intent. A wrong record can be written and has to be corrected afterwards; only destructive actions wait for consent.
Back to the R$ 85
“Spent 85 at the supermarket” ends as one row: 85.00, in the household’s currency, under groceries, attributed to the member who sent it, claimed once, and confirmed in a sentence the code wrote. The conversation stayed simple. The engineering stayed behind it, in the places where it can be tested.
The question I keep coming back to is measurement. The application side has tests that pin its behavior; the model side has instructions and gates. None of what follows exists yet. The next real step is a versioned evaluation set, run against each prompt and model version, that covers:
- tool selection: did the agent call the right tool, with the message passed along unchanged?
- ambiguous messages: did it ask instead of guessing?
- multilingual phrasing, in the three languages the parser reads;
- receipt extraction, field by field, including the confidence the model reports;
- regressions between one prompt or model version and the next.
After that comes seeing it in production: model and tool latency, which tool was chosen, tokens and cost, and one trace per message that ties the model’s turn to the tool call and the record. Then changing a prompt or a model becomes something I can measure instead of something I can only hope works.
You can see Lukre and what it does, or browse the rest of the writing.
