Building atula
What building agents in iMessage taught us about capability, status, and trust.
Nothing had started. The agent produced a perfectly reasonable acknowledgment, twice, without ever calling the tool that would do the work. I want to walk through why that happens, because the fix turned out to be less about the model and more about how we build the product around it.
We're building atula, a personal growth agent that lives in iMessage. It helps users plan their goals, creates a variety of live sessions, sends links and reminders, and chats whenever needed. There's no app to install or separate product interface to navigate. You just text it.
That simplicity, just text it, is the whole appeal. It also changes what an interface has to do.
A button can tell you which actions a product supports. A progress indicator can tell you whether your request is underway. In a conversation, a sentence can carry both meanings. "I'll send it" tells the user that the capability exists and that the system has taken responsibility for using it.
The user can move on. The software, in this case, hadn't moved at all.
Moving everything into a conversation didn't remove the need to show progress. It made our words responsible for communicating it.
Two principles shape almost every decision that follows:
And both principles run straight into two facts we can't design away: the model's performance is probabilistic, and an agent can talk fluently about far more than it can actually do. Most of the stories below are what happens when those facts surface in a real conversation.
The missing document exposed a gap between generating an answer and executing an action. The reply alone couldn't tell us whether the work had started. We had to check it against what the system actually did.
This is where those two principles meet in a single line of code. The model that's cheap enough to answer all day is also the one most likely to narrate an action without taking it, so we can't read a fluent reply as proof the work happened. For us, choosing a model tier and deciding how to verify it are the same decision.
Our first lesson: inferring the model's decision from its prose or reasoning wasn't reliable enough. Considering an action can sound just like deciding to take it.
So we made the model declare a structured intent: start, ask, or chat. Code reads that declaration. If the model declares start but hasn't called the corresponding tool, the system triggers it. If it declares ask, the system leaves room for clarification.
The declaration can still be wrong, but it gives us an explicit decision to check, instead of more prose to interpret.
There's also a subtlety in what a passing check even proves. That preparation started is not that the link arrived: dispatch and completion are different facts, and checking once, at the moment we reply, only covers the first.
iMessage adds a complication: the conversation can pause while work continues. A job can start correctly, then hang after the reply has gone out.
We handle that with two checks:
A watchdog checks open promises on a timer, retries transient failures, and sends a recovery message when the work can't be completed, so a promise that dies while the user is waiting still gets caught, not just one that fails at reply time.
Two checks around one probabilistic core
Both belong to the interface, even though neither looks like a screen.
Knowing what actually happened is only half the job. The other half is saying it, and that turns out to be its own problem. atula is a companion and a partner, and warmth is part of why someone would want to keep talking to it. But "Absolutely," "I've got you," and "I'll take care of it" can also imply capability and progress.
We tried tightening the prompts. Twice we shipped a change aimed at a specific fabrication, and twice we rolled it back after user testing showed the broader experience had regressed: caution spread into turns where there was no commitment to make, and the agent hedged when it didn't need to.
The more useful boundary turned out to be who writes which part of the message. For session preparation, the model contributes the warm opening, and its instructions explicitly forbid it from claiming that it's preparing the session or will send a link. Code appends the commitment, and if the work fails, the system, not the model, writes what comes next.
Who writes which part of the message
Here is the same session-prep request with and without that split. The right side is shipped copy, the recovery selected by the system when preparation fails and retries are exhausted:
The file containing those recovery messages has a direct instruction: "Keep these as constants (not model-generated)."
A small implementation detail that carries a larger product choice: the system owns statements about its state; the model has room to express warmth around them.
The recovery doesn't fulfill the request. The user still didn't get the session. But it says what happened and offers something the product can actually do, so they don't come back hours later to discover the failure themselves.
Writing those recovery messages forced us to confront a product question we hadn't fully answered: when the main path fails, what can we honestly offer next?
Another user asked atula to add a recap link to their curriculum page. The agent replied:
That automatic sync didn't exist.
This was a different failure: there was no supported action to start. The agent had described a feature we hadn't built.
In a conventional interface, we'd just omit the button. In iMessage, the user can ask anyway. That's a real benefit of conversation: people express what they want without learning our menu. But the product still needs a way to communicate its limits.
We added an unsupported_request tool. When a request matches no capability, it records the ask and hands the agent guidance for an honest response, including an alternative.
Every request for action exits through a tool
For the recap request, that means saying the page update isn't supported yet and offering to resend the link. The line "I've shared it with the team" is true because the tool logged it first:
It also gives us a record of what users expect: the missing button becomes product feedback.
We deliberately work with a fixed pool of tools because it makes the supported actions easier to inspect and test. It doesn't stop every false claim: the model can misroute a request, and prompt rules can drift. The tool set also needs maintenance as the product grows.
Sometimes the right response is to add the missing capability. When a user corrected a name in her plan, atula claimed it had updated its notes while the documents stayed wrong. We added a correct_plan tool so the requested correction had an execution path.
The boundary between supported and unsupported behavior is ours to design and maintain, even when the interface makes it invisible.
A smaller version of the same problem: atula also produced fabricated video links, and a convincing message with an unusable link still leaves the user at a dead end.
We restricted where links can come from: URLs already shared in the thread or results returned by a search tool. The model selects from evidence instead of composing a URL from memory. Our link guard trusts links already in the thread and checks new ones; if it can't verify a new link, it removes the link and its accompanying label.
These checks have limits. A sourced link can be irrelevant. A reachable page may still be inaccessible to a particular user. Previously shared links can become stale. Source and reachability checks address specific failures; they don't establish that every link will be useful.
We also can't afford to treat every sentence as a separate verification project. Checks add latency, maintenance, and sometimes inference cost. Our prompt rollbacks showed that restrictions can also damage the conversational experience.
So we prioritize checks where there is a cheap external signal and a visible consequence for the user. Links got a code-enforced guard. An unsupported attribution to "industry estimates" got a prompt rule requiring a search for attribution, with stronger enforcement deferred in writing pending recurrence.
That leaves risks we still carry. Naming them is part of the work. Adding a guard doesn't finish the reliability problem; it changes what remains to be reviewed.
This is the machine we've built so far. One channel in front, and behind it every piece the stories above added:
One text thread, a lot of machinery behind it
The broader engineering challenge is familiar: quality was the leading production barrier in LangChain's survey of more than 1,300 professionals. Our experience gives that broad category a concrete shape: whether the product's words accurately represent its capabilities and progress.
For me, this is one of the central design problems in building consumer products around agents. We can let users express what they want in ordinary language, but we still owe them clear boundaries, dependable status, and a useful response when something fails.
Those responsibilities used to be distributed across buttons, loading states, and error screens. In atula, much of that work has to happen inside a message that still feels natural to receive.
The question I want us to keep asking is: what will the user believe they no longer need to do after reading this, and has our system actually taken responsibility for it?