The generator worked. Nothing delivered it.
An AI plan was generated, saved, billed for, and never sent. The promise to deliver it existed in the code as a string that was never called. Here is what it takes to actually get a generated document to somebody on a channel that only lets you speak when it decides you may.
What this system does
Agentora's consumer assistant generates a personal growth plan on a container worker and sends a short daily brief each morning. Both are produced asynchronously and then have to reach the person on WhatsApp. This case study covers the delivery half — not how the content is made, but how it gets there.
The problem
The first sign was a question from a user: the plan said it was ready, so where was it? The generation pipeline was healthy. The job had succeeded, the document was in the database, the model call had been paid for. Nothing had ever attempted to send it.
The code contained the intention. The conversation flow ended by telling the person a message would arrive when the plan was ready, and the copy for that message existed in three languages. Nothing called it. The feature had shipped as a promise with no implementation behind it, and no error anywhere — an unsent message raises nothing.
It stayed invisible because every plan until then belonged to somebody who also had a web login and simply opened the site. The first account created entirely inside a chat was the first user with no other way to receive it.
The same gap sat under the daily brief, with a harder constraint on top: the messaging platform only permits a free-form message within twenty-four hours of the person's own last message. A brief that goes out early in the morning arrives precisely when nobody has written since yesterday, so the ordinary case is that the send is refused. Not occasionally — most mornings.
Constraints
The boundaries the design had to respect, before any solution was chosen.
- A generated document is expensive and already paid for, so a delivery failure must be recoverable rather than terminal.
- A plan spans several messages; a person must never receive part of one twice.
- The container worker that generates content holds no messaging credentials, and the application that holds them is only running when something calls it.
- The hosting plan allows two scheduled jobs, and both were already committed to other work.
- Outside the platform's service window only a pre-approved template may be sent, and a template cannot carry the content — its text is fixed at approval time and its parameters are short single-line strings.
The architecture
Delivery is modelled as its own durable step with its own state, rather than as the tail of generation. A finished document is a row waiting to be sent, and several independent things are allowed to try — which makes the interesting problem exclusion rather than triggering.
- 1
Delivery is a state on the job, not an event
The job row carries when it was delivered, how many attempts have been made, the last error, and whether a re-engagement template has been sent. A succeeded job with no delivery timestamp is the queue: anything may sweep it, and the columns are the whole coordination mechanism.
- 2
Claim before sending, not after
The delivered timestamp alone was not enough. It is written after the LAST message of a document has gone out — for a nine-message plan, close to a minute of sending — so two sweeps starting inside that window both saw an unsent job and both began. A separate claim column is taken first, in a single conditional UPDATE; the loser gets zero rows back and sends nothing. The claim is treated as stale after ten minutes so a sender that dies mid-flight releases the document rather than parking it forever.
- 3
Three independent triggers
The worker pokes the delivery endpoint the moment generation finishes. Any inbound message from that person sweeps again, which matters because their message is exactly what reopens the service window. A heartbeat catches whatever both missed. All three are idempotent by construction, so the redundancy costs nothing but a query.
- 4
The database is the scheduler
The worker cannot send and the application is not always running, so the heartbeat lives in Postgres: a scheduled job calls an HTTP request out to the delivery endpoint. It is guarded by an indexed existence check, so on the overwhelming majority of runs it does one cheap query and makes no request at all.
- 5
A template to knock, not to carry
When a send is refused because the window has closed, a pre-approved template goes out instead — a single line saying the document is ready. The person's reply is what reopens the window, and the sweep that runs on every inbound message delivers the real thing seconds later. The template send is recorded so the knock happens once per document rather than on every heartbeat.
- 6
Late is not the same as stale
A daily brief that failed to send yesterday must not arrive today; it describes a day that has passed. Delivery therefore checks that the document is still for the current date and abandons it otherwise, which is a different decision from retrying — the retry is bounded by relevance, not only by attempts.
Key decisions and their trade-offs
Every decision below cost something. The trade-off is stated alongside the reasoning.
Put the recurring trigger in the database rather than the application
Why
The component that generates content holds no messaging credentials, the application only runs when called, and the hosting plan's scheduled-job slots were already spent. The database is the one part of the system that is always awake, knows the instant a job succeeds, and can make an outbound request.
Trade-off
Scheduling now lives outside the application repository, so a change to it is a migration rather than a deploy, and it is invisible to anyone reading only the application code.
Guard the heartbeat on whether anyone is reachable, not on whether work exists
Why
The first version asked only whether an undelivered document existed. Documents belonging to people with no messaging number are skipped by the sweep without counting as failures, so that condition stayed true permanently and the heartbeat made a pointless request every single run.
Trade-off
The guard now duplicates the sweep's own eligibility test in SQL, so a change to who counts as reachable has to be made in two places.
Accept at-least-once triggering and enforce exactly-once by a claim
Why
Three overlapping triggers make delivery robust to any one of them failing. Trying to make the triggers mutually exclusive would be far harder than making the send exclusive.
Trade-off
Correctness now depends on the claim being taken in a single atomic statement. Any future path that sends without claiming first reintroduces the duplicate.
Use a template as a knock rather than trying to deliver content in one
Why
Outside the service window a template is the only thing the platform will deliver, and its fixed text cannot hold a multi-block document. Asking for a reply converts an undeliverable moment into a deliverable one.
Trade-off
It depends on the person replying. Someone who ignores the knock never receives that day's brief, and the system cannot tell the difference between not seen and not wanted.
Technologies used
Scheduling
- pg_cron
- pg_net
- Guarded existence check before any outbound call
State
- PostgreSQL
- Partial index on undelivered work
- Conditional UPDATE as a claim
- Stale-claim expiry
Delivery
- WhatsApp Cloud API
- Pre-approved re-engagement templates
- Message chunking on paragraph boundaries
Outcome
- Documents that had been generated and never sent were delivered on the first sweep after the mechanism existed, without regenerating anything.
- The daily brief now runs unattended: on the first full morning it enqueued, generated and delivered to every eligible subscriber with no errors and no manual step.
- A concurrent-delivery race that had already sent one person part of a document twice is closed by a claim taken before the first message rather than a timestamp written after the last.
- The heartbeat is quiet by design — on runs where nobody is waiting it performs one indexed query and makes no outbound request.
Known limitations
What this design does not do. Stated because an architecture without documented trade-offs has usually not been examined closely enough.
- Delivery outside the service window depends on the person replying to a knock. If they never do, that day's brief is never delivered, and the system cannot distinguish disinterest from not having seen it.
- The claim expires after ten minutes to release documents held by a dead sender. A sender that hangs for longer than that without finishing could still produce a duplicate.
- Acceptance by the messaging platform is not the same as being read. There are no read receipts in this data, so open rates cannot be measured, which also means a feature gated on open rate has to be judged another way.
- The scheduler abandons its view of a run after a fixed timeout. The work still completes, but a run slower than that leaves no record of its result — a thing that looks like a failure while being fine, which is worse than a plain failure.
- The platform's own per-day messaging ceiling, not this design, is the real limit on how many people can receive a daily message.
- The heartbeat polls on a fixed interval rather than reacting to a state change, so delivery has a floor of roughly that interval when the immediate trigger is unavailable.
Want this level of rigour on your AI initiative?
Start with a free AI Readiness Assessment, or book a Discovery Workshop to get a scored, costed roadmap.