Engineering NotesDesign principles and architecture
One agent loop for every feature, with the differences in hooks

Five features in OwlAI have a model working by calling tools: chat; playbooks, which run a defined procedure with no one watching; schema analysis, which reads the tables of a connected database ahead of time; meeting summaries, which summarize each segment while the meeting is under way; and the researchers that the lead spawns in deep research. The agent loop that runs them executes tools, hands back results, and enforces rules whose absence corrupts results without raising an error: don't leave an empty result when the round limit is reached, and when the user cancels, stop the database queries already sent.
Those rules have to be the same in every feature. So all five features run the same loop, and we limited what differs per feature to four things: the system prompt, the tool list, hooks, and where the conversation history is kept. A hook is a function a feature plugs into a fixed point in the loop. This post covers the rules the loop enforces, how hooks express per-feature differences, and why we chose a single while loop over a graph framework.
The loop's rules have to be the same in every feature
Below, a round means calling the model once and executing the tool calls in its response. A turn is one exchange, from receiving a question to producing the answer, and a turn runs many rounds. The loop enforces these rules every round.
| Rule | What happens without it |
|---|---|
| Attach exactly one result to every tool call, including a refusal reason for calls it declines to run | If a call without a result stays in the history, the model provider rejects the entire next request with a 400 |
| At the round limit, remove the tools and call the model once more so it answers with what it has gathered | The response at that point usually holds only tool calls and no text, so ending there saves an empty result |
| When the same tool fails repeatedly, stop and have the model answer | The loop repeats the same calls without progress until the time runs out |
| Count a result as failed only when it carries a failure marker | Normal results that don't include a success key get counted as failures, and healthy work gets cut off |
| When the user cancels, cancel database queries already sent | A query holds the database until its time budget (timeouts.dbQuerySeconds, 600 seconds by default) runs out |
| Sort the tool list by name before sending it | If the same tools go out in a different order on each call, the start of the prompt changes and prompt caching misses |
A loop tailored to each feature lets each team ship without worrying about the others. The cost is a copy of every rule above in every loop. Put the round-limit wrap-up only in the chat loop, and the analysis loop saves empty results at the same point. The meanings drift too. If one loop counts "the result's ok is not true" as failure and another counts "an error marker is present," a normal result with no ok key fails only in the first loop.
Defects like these leave wrong results without errors, so they're hard to find. That is why the rules live in the loop, in one place.
Only four things differ per feature: prompt, tools, hooks, and history location
A feature passes its system prompt and tool list when it calls the loop. Behavior specific to a feature goes in hooks. Hooks are async functions that the loop calls at fixed points in every round. Five points do most of the work.
| Point | When it's called |
|---|---|
| Round start | After outside input is added to the history, before this round's model call |
| New user message | When the user says something new mid-turn |
| After the model responds | After the model responds, before tools run |
| Answer without tools | When the model answers without calling a tool |
| After tool results | After tool results are added to the history |
Hooks return what the loop should do, and the loop edits the history: items to append to the history, a replacement list of calls to execute, end with this text, remove tools from the next round on, or a tool to use in the next round. The loop applies those values while keeping one result attached to every call. If hooks edited the history themselves, each hook would have to keep that order on its own.
In deep research, once the lead has used its allotted calls for scoping, the round-start hook sets the next round to use the tool that shows the research plan to the user. Schema analysis and meeting summaries run with the default hooks, which do nothing.
The fourth difference is where the history is kept. Chat adds a node to the conversation tree each round and keeps the history in the database. Schema analysis, meeting summaries, and researchers accumulate history in an in-memory list held by the caller. The loop makes only three requests—give me the history, append this item, call the model with this request—and doesn't know where the history lives. So failure detection and the pre-execution checks are the same code for both kinds of storage.
Each playbook run creates a conversation that doesn't appear in the list and runs a single chat turn on it. The history goes into the conversation tree as in chat, and tool discovery, permission checks, and the base prompt are the same as chat's. A single step-completion tool marks the end of each step. A structure that starts a loop per step and hands database queries to subagents looks tidier, but subagents can't see the parent's history, and on the same work it used more tokens and produced worse results.
Tool calls pass three checks before they run
When a round starts, the loop first checks the time limit and cancellation. Then it adds outside input—subagent results, or something the user sent mid-turn—to the history. This is the point where every call from the previous round has its result, so inserting here keeps other items from landing between a call and its result. Next, the loop sorts the tool list by name and calls the model.
When the model calls tools, three checks run in this order before execution:
- Check whether the calls exceed the total tool-call cap the feature set (schema analysis allows 40 per table). Run calls from the front up to what remains, and attach the reason it didn't run to each call over the cap.
- Expand a call that invokes another tool on the model's behalf into a call to the tool that will actually run.
- Let the checks the feature turned on refuse calls before execution: a repeat of a call with the same arguments, calls beyond the cap on approval-required calls, and other rules the feature defines.
Step 2 has to come before step 3. Step 3, and the permission check and user approval after it, judge by tool name and arguments. Judge before expanding, and the check passes the delegating tool, after which the real tool that needs approval runs without it. A refusal only blocks execution; the loop keeps going. The refusal reason has to come back as the tool result so the model can choose another approach in the next round.
After execution, the loop counts repeated failures, and if they trip, it removes the tools and has the model answer. Even then it sends the tool list unchanged, to keep prompt caching intact, and passes only the intent "don't use tools this time."
The 40-round limit is a safety net; failure detection does the stopping
The round limit is 40 per turn. It counts rounds, not tool calls: ten tools called together in one round count once. Playbooks multiply 40 by the number of steps and put a separate time limit on the whole run (two hours by default). When the user says something new mid-turn, the loop treats it as a new request and starts counting again.
We set this value from 2,906 turns observed in production. Round counts had a p95 of 14, a p99 of 22–26, and a maximum of 127. The 127-round turn was a normal long-running task that called tools 117 times, all successfully. A limit of 30 cuts off work like that, and raising it to 50 gained nothing over 40. Turns that spun without progress showed up as repeated failures, not as round counts. So failure detection does the stopping, and the round limit stays as the last safety net.
Failure detection stops when the same tool fails five times in a row. It keeps counting even when the arguments change or other tools run in between. In the same observations, a threshold of three caught 12 normal turns, and five turns had five or more consecutive failures.
What counts as a failure and the threshold of five are the same code in every feature; only what happens after detection differs, through hooks. In chat and playbooks, the after-tool-results hook has the model answer with a notice explaining why it stopped. For researchers, the loop has the model answer right away. Researchers also stop when they repeat the same search with slightly different arguments. Arguments alone don't reveal that, so the loop counts consecutive rounds whose results are all ones it has already seen.
Fix it once and all five features change
Cancellation is one example. The loop makes its cancellation signal readable by the tools it runs, and the SQL execution tool turns that signal into a database-side query cancel. Because that link is in the loop, cancelling in chat, a playbook, schema analysis, or a researcher stops queries already sent (“Read-only and query cancellation have to reach the database server”).
The same goes for the mechanism that sets the original of a large tool result aside in a file and lets the model reread it (“Query results go to a file so the model never retypes them”). Any run that carries at least one tool gets the file reread tool from the loop by default. So schema analysis and meeting summaries can also reread a result that went over the limit.
Branches are hook return values, not graph edges
Many teams building agents start by picking a graph framework such as LangGraph. The flow is visible at a glance, and the framework takes care of saving intermediate state and interrupt/resume.
A tool-calling loop is a single while statement: keep going while the model calls tools. It has many forks, but they are all decisions made within one round, such as whether to refuse, whether to remove tools, or which tool the next round should use. Those decisions read more clearly as hook return values than as edges.
The two pipelines in schema analysis (table analysis and database summary) also need only "run nodes in order and stop on an error." They don't use cycles, saved intermediate state, or interrupt/resume, and every conditional branch is the same error check. Adding LangGraph only for that brings 15MB of dependencies and 0.58 seconds of import time, plus an external SaaS telemetry SDK that doesn't belong in an on-premises product image. StateGraph also drops keys that aren't declared in the state type as state passes between nodes, so a value whose declaration was missed disappears without an error. So the two pipelines run on a runner of about 100 lines, and branching is written as a single condition, "does this node run," instead of "where do we go next."
Design choices
Keeping one loop is a choice to let fixes and defects reach every feature together. The price is that every change to the loop has to be checked for regressions across all five features.
Feature-specific behavior lives only at the fixed hook points, and we add a point only when a new requirement doesn't fit an existing one. Adding points freely would fill the loop body with per-feature branches again.
Schema analysis, meeting summaries, and researchers keep history in memory and save no intermediate checkpoints. These jobs are short, so we kept storage simple; the cost is that a job stopped midway has no point to resume from.
Deep research is the only feature that spawns subagents. Because it's the same loop, spawning could be opened anywhere, but deep research was the only place where running several investigations at once outweighed the cost of children not seeing the parent's history.
If you have two or more features that call tools and the differences between them can be described as a system prompt, a tool list, hooks, and a history location, one loop is enough. If you already have several loops, start by comparing how each one implements the rules in the table above. That is where the hidden differences show up.
What problem does this solve?
More in Design principles and architecture

Engineering Notes · Design principles and architecture
Permissions are enforced in code, judgment is left to the model
OwlAI's agent reads internal databases and documents, and it also reads web pages and results from connectors…

Engineering Notes · Design principles and architecture
RAG can't count or sum rows, so we ask the source database in SQL
Ask "How many orders have gone unprocessed for more than 30 days?" through document search (RAG), and the mode…
