The bank app is the wrong surface for a daily transfer.
WhatsApp penetration in Venezuela sits above ninety percent; banking apps are the reluctant opposite — clunky, inconsistent, and insistent on pulling the user out of the conversation they are already in. Pago móvil, the country's domestic transfer rail, demands a recipient phone or ID, a bank code from a list of twenty-odd, an amount, and a one-time code. For a frequent, low-value payment, it is a lot of ceremony for a small gesture.
The wager here is narrow and specific: the transaction should live in the same chat the user is already having. No app switch, no form, no sixth tab. The user types the sentence, the system does the translation, and the money moves.
The product is not an AI feature on top of a banking app. It is a banking app collapsed into a conversation.
A state machine with an agent at every node.
The shape that held up was a conversation state machine where each state owns a specialised agent and a narrow job:
IntentDetectionreads the incoming message and decides whether this is a payment, a status check, or a customer-service turn.PaymentDataCollectiongathers recipient, bank, and amount over as many turns as it takes, filling a structured payload.OtpCapturerequests and receives the six-digit code from the user's bank.PaymentExecutionhands the payload to Cobra Fácil via a Saloon connector and parks the conversation in a non-agent monitoring state.Success/Failed/CustomerServicehandle the aftermath.
Transitions are an adjacency list declared in a StateGraph and
validated on every hop. Each ConversationState resolves its own agent
out of the container — the state is the router. All agent outputs land
in a single JSONB metadata column on the Conversation, keyed by
state name, which gives every downstream agent one place to read from
and one place to write to.
The web side is Laravel plus React on Inertia, passwordless login via six-digit emailed codes, and a dashboard where the user registers their payment methods once. Onboarding is a gate: if a WhatsApp message arrives from a user with no default method, the flow terminates with a link to the dashboard and no conversation is even created. The chat is only usable once the account is ready for it.
Native in-chat payments on real bank rails, 24+ banks.
The system answers in Spanish, executes on Venezuelan bank rails through
Cobra Fácil, and keeps the WhatsApp webhook path fast by isolating
LLM-heavy work on its own queue. Distributed locks (wa:inbound:{phone}
on intake, per-conversation locks on agent routing) keep racing messages
from stepping on each other. Webhook idempotency is enforced by a unique
index on whatsapp_message_id. Horizon is gated by an email whitelist —
pragmatic, not elegant, and chosen deliberately over a heavier auth layer
for a small team.
The one piece that is more interesting than it looks is payment status. Cobra Fácil does not push outcomes back, so a console command polls in-flight transactions every five seconds and emits success or failure events that nudge the conversation forward. It is simple, it works, and it makes the scheduler a single point of failure we watch externally.
The interesting decisions were about where to put the complexity.
Three choices still feel right. First, one JSONB column per conversation as the agent scratchpad — every state writes its output under its own key, and every downstream agent reads from the same place. Schema changes do ripple, but debugging a conversation is one row and a printout, not a join across five tables.
Second, polling over webhooks for payment status. Cobra Fácil did
not give us a push, and rather than simulate one we leaned into the
pull. The state machine parks the conversation in payment_status_monitoring
and the poller kicks it when the world is ready. A watchdog on the
watcher is the obvious next step.
Third, event-driven job chaining over a synchronous saga. A
NextAgentCalled event fires a listener that re-dispatches a
RouteToAgentJob. Each hop is its own job on the agent queue,
observable in Horizon, retryable in isolation, and invisible to the
webhook thread.
What I would do differently is the safety net around the scheduler and the conversation lifecycle. There is no explicit conversation closure yet — expiry sweeps it — and no in-process watchdog on the payment poller. Both are known gaps, both are deferred, and both are the kind of omission that only costs you once.