Project № VII · Case study

PagaFácil — pago móvil, but it's a conversation

A WhatsApp-native payment assistant for Venezuela. The user says 'send 500 to Juan' and the system does the rest — collecting the recipient, asking for the OTP, executing the transfer on Venezuelan bank rails, and reporting the outcome — without ever leaving the chat. Built on a state-machine-driven multi-agent pipeline over Laravel and a Cobra Fácil integration.

I.Problem

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.

II.Approach

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:

  • IntentDetection reads the incoming message and decides whether this is a payment, a status check, or a customer-service turn.
  • PaymentDataCollection gathers recipient, bank, and amount over as many turns as it takes, filling a structured payload.
  • OtpCapture requests and receives the six-digit code from the user's bank.
  • PaymentExecution hands the payload to Cobra Fácil via a Saloon connector and parks the conversation in a non-agent monitoring state.
  • Success / Failed / CustomerService handle 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.

III.Outcome

Native in-chat payments on real bank rails, 24+ banks.

7
Specialised agents behind one state machine
24+
Venezuelan banks on the pago móvil catalog
3
Segregated Horizon queues keeping LLM latency off the webhook path

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.

IV.Retrospective

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.