<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anis Meziani</title>
    <description>The latest articles on DEV Community by Anis Meziani (@anis_meziani_52aab42304a8).</description>
    <link>https://dev.to/anis_meziani_52aab42304a8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4135967%2F159246f4-f42f-410f-8cd6-684fdb5af70c.png</url>
      <title>DEV Community: Anis Meziani</title>
      <link>https://dev.to/anis_meziani_52aab42304a8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anis_meziani_52aab42304a8"/>
    <language>en</language>
    <item>
      <title>How to cut your AI agent's input tokens: what worked, what failed, and a bench to measure yours</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:40:29 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/how-to-cut-your-ai-agents-input-tokens-what-worked-what-failed-and-a-bench-to-measure-yours-f90</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/how-to-cut-your-ai-agents-input-tokens-what-worked-what-failed-and-a-bench-to-measure-yours-f90</guid>
      <description>&lt;p&gt;Before every single thought, our BI agent re-read 17,000 tokens of instructions and tool manuals.&lt;/p&gt;

&lt;p&gt;Then the whole conversation. Then every table it had pulled since the first question. In our offline replay of a three-question conversation, ten model calls consumed &lt;strong&gt;286,522 input tokens&lt;/strong&gt; before optimization. Real runs behaved differently, as you'll see below.&lt;/p&gt;

&lt;p&gt;Nobody had decided that. It's just what an agent does by default: every model call starts from zero, so everything the agent "knows" gets sent again. Every call, every turn, every retry.&lt;/p&gt;

&lt;p&gt;This post is a field report on how we brought it down: how we measured, which cuts paid off and in what order, &lt;strong&gt;what didn't work&lt;/strong&gt;, and a checklist you can run on your own agent tomorrow. It ends with five questions we can't answer alone. The optimizations reported here are implemented, and the benchmark is open source, so you can check every number. Some experiments and benchmark improvements are still in progress, and I'll say which.&lt;/p&gt;

&lt;p&gt;It's the third post in a series about what breaks when AI agents run in production, after &lt;a href="https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80"&gt;agents that forgot everything after a restart&lt;/a&gt; and &lt;a href="https://dev.to/anis_meziani_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap"&gt;agents that redid work they had already done&lt;/a&gt;. This one is about the opposite of amnesia: agents that remember too much, and pay for it on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: six lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure before you cut.&lt;/strong&gt; Without a bench and quality checks, our best-looking cut would have quietly broken the answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with the tools.&lt;/strong&gt; Instructions plus 36 tool declarations cost 17,000 tokens before the question. Removing the tools the agent never calls was the cheapest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forget the past, never the present.&lt;/strong&gt; Shortening old tool results saved 21 to 26%. Shortening the current turn broke quality on 4 scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable prompts are cacheable prompts.&lt;/strong&gt; In our benchmark, ordering the schema tables deterministically raised the reusable prefix of a SQL prompt from 11% to 98%, with the same tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The biggest real gain came from fewer calls, not smaller ones:&lt;/strong&gt; −41% on a question, because the model asked once instead of five times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your bench will lie to you a little.&lt;/strong&gt; Ours overestimated real gains by 9 to 16%, underestimated one by a lot, and missed a failure entirely.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. Measure before you cut
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Know what you're paying for
&lt;/h3&gt;

&lt;p&gt;Here's what our BI agent sent to the model on every call, before we changed anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Paid when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instructions&lt;/td&gt;
&lt;td&gt;the agent's system prompt&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool declarations&lt;/td&gt;
&lt;td&gt;name, description and schema of each of 36 tools&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;every message of the conversation&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Past tool results&lt;/td&gt;
&lt;td&gt;every table, file and query result pulled so far&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current turn&lt;/td&gt;
&lt;td&gt;the question, and the results the model is working on now&lt;/td&gt;
&lt;td&gt;every call of this turn&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two layers alone came to about &lt;strong&gt;17,000 tokens&lt;/strong&gt;: the whole first call weighed 17,070, a one-line question included. The model paid them before reading a word of the question, and again at each of the 3 to 5 calls a question takes.&lt;/p&gt;

&lt;p&gt;We're not alone. The industry now calls it the "MCP tax": &lt;a href="https://getunblocked.com/blog/mcp-token-budget-autopsy/index.md" rel="noopener noreferrer"&gt;one measurement published in May&lt;/a&gt; found around 71,000 tokens of overhead for seven MCP servers, about 35% of a 200K context window, before the first prompt. And the cost isn't only money: research on &lt;a href="https://morphllm.com/context-rot" rel="noopener noreferrer"&gt;context rot&lt;/a&gt; keeps showing that models get worse as their input grows, well before the window is full. A useless token is paid twice, on the bill and in the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build the bench first
&lt;/h3&gt;

&lt;p&gt;We built a bench before touching anything. It lives in &lt;a href="https://github.com/apowerb/apowerb/tree/main/bench/bi_tokens" rel="noopener noreferrer"&gt;&lt;code&gt;bench/bi_tokens&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five scenarios&lt;/strong&gt; of our BI agent: creating a dashboard, asking a question about it, chatting with it, a three-question conversation, and a forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An offline replay&lt;/strong&gt; with a scripted model, so each change is compared on identical calls, input tokens counted call by call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality checks&lt;/strong&gt; on every scenario: the right facts, grounded in the data. A cut that saves tokens but fails a check is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real runs&lt;/strong&gt; afterwards, with costs read from the usage table where every model call is recorded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters more than it sounds. Section 3 explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The cuts, ranked by effort and gain
&lt;/h2&gt;

&lt;p&gt;Here's what we did, in the order we'd do it again:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cut&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;Measured gain&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remove the tools the agent never calls&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;−10 to −15% per scenario, first call −18.5% on real runs&lt;/td&gt;
&lt;td&gt;nothing observed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shorten old tool results, past turns only&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;−21 to −26% on conversations&lt;/td&gt;
&lt;td&gt;never touch the current turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Describe big payloads instead of pasting them&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;base64 page images no longer re-sent at every turn&lt;/td&gt;
&lt;td&gt;the agent must still reach the full data through tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make the prompt prefix cache-friendly&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;reusable prefix 11% → 98% (benchmark, upper bound)&lt;/td&gt;
&lt;td&gt;depends on your provider and on which tables are selected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fewer round trips&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;−41% on a question, real run&lt;/td&gt;
&lt;td&gt;only pays if the model uses it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trim the instructions&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;about −2%&lt;/td&gt;
&lt;td&gt;small, do it last&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Remove the tools the agent never calls
&lt;/h3&gt;

&lt;p&gt;Our BI templates had inherited the generic chat tools of the platform, the ones a general assistant needs and a BI agent never calls. Most agent stacks ship this way; we just measured ours.&lt;/p&gt;

&lt;p&gt;We removed ten of them from the BI templates, and trimmed what remained: declarations lost their pydantic titles and "Returns" sections, and tool responses are capped. 36 tools down to 26, and the first call went from 17,070 to 13,598 tokens on the bench. On real runs, together with the instruction trim described below, it went from about 14,500 to 11,800.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; an agent's toolbox is a cost line. Declare tools per agent, not per platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shorten old tool results, never the current turn
&lt;/h3&gt;

&lt;p&gt;After a few questions, most of the context was old tool results: tables the agent had pulled two turns ago and already answered from. Now, in every turn &lt;em&gt;before&lt;/em&gt; the current one, a large tool result keeps its small fields (ids, titles, status, a message) and loses its bulk, with a note telling the model it can call the tool again if it needs the data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compact_previous_turns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The current turn starts at the last user message that isn't a tool result.
&lt;/span&gt;    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_turn_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;          &lt;span class="c1"&gt;# earlier turns only
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_large_tool_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keep_small_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ids, titles, status
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs on a copy: the stored session keeps everything, only what the model re-reads is shortened. And the answer the model gave at the time stays in the history, so what it concluded isn't lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; storing everything is cheap. Re-sending everything is what costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Describe big payloads instead of pasting them
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Big files.&lt;/strong&gt; A large CSV used to be pasted into the context. Now the agent gets the header, the first 20 rows and the row count, and works on the full data through its tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images.&lt;/strong&gt; In our pipeline, a base64-encoded PDF page was estimated at roughly 100,000 to 150,000 text tokens, depending on the page and its encoding (an estimate written down in our code when we hit it, not a formal benchmark), and it used to be re-sent at every later turn. Now images go to the model as images, and base64 is stripped from older turns. The model saw the page once; its own description of it is the source of truth after that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; the model needs to know what's in a file, not to hold the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the prompt prefix cache-friendly
&lt;/h3&gt;

&lt;p&gt;Providers cache prompt prefixes, but only if the beginning of your request is &lt;em&gt;exactly&lt;/em&gt; the same as before. Our text-to-SQL tool injected the relevant tables of the schema in order of relevance. Relevance changes with every question, so the prefix did too.&lt;/p&gt;

&lt;p&gt;We now write the selected tables in name order: same tables, same tokens, only the order changes. Share of each request that repeats the previous request's prefix, in our benchmark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Relevance order&lt;/th&gt;
&lt;th&gt;Name order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25 tables, all selected every time&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;80 tables, 62 or 63 selected per question&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;
  How we measured this, and what the number is not
  &lt;p&gt;&lt;strong&gt;The method.&lt;/strong&gt; The benchmark sends 10 fixed questions against a fixed schema. For questions 2 to 10, it counts how much of each request matches the previous request's prefix, using OpenAI's caching rules: prefixes of at least 1,024 tokens, counted in 128-token blocks. The tokens sent and the tables selected are identical before and after; only the order changes. The script is &lt;a href="https://github.com/apowerb/apowerb/tree/main/bench/sql_prompt_cache" rel="noopener noreferrer"&gt;&lt;code&gt;bench/sql_prompt_cache/measure.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the number is not.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not the share actually served from the cache.&lt;/strong&gt; That depends on the provider routing your requests to the same cache, and only shows up in the cached tokens your provider reports. Ours is an upper bound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a cost reduction.&lt;/strong&gt; Cached tokens are cheaper, not free, and pricing differs by provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a guarantee.&lt;/strong&gt; Ordering alone doesn't make a prefix stable. It works best when the same tables come back from one question to the next, which is why the 80-table row is lower.&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Two more things we learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some providers cache nothing unless you ask.&lt;/strong&gt; On Claude models, nothing was cached until we set cache markers: every call of a turn paid the instructions and tool declarations, 5,000 to 17,000 tokens depending on the agent, at the full rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count cached tokens for what they cost.&lt;/strong&gt; Our usage quotas counted cached input at full price. They now count it at 25%, otherwise users are charged for savings already made.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; keep the stable parts of your prompt stable. When your provider supports prompt caching, deterministic ordering can turn repeated context into reusable context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fewer round trips
&lt;/h3&gt;

&lt;p&gt;Every extra model call re-pays the whole stack. So the cheapest call is the one that doesn't happen.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One grouped query instead of five.&lt;/strong&gt; We gave the dashboard query tool a &lt;code&gt;group_by&lt;/code&gt; parameter. On a real run, the model asked one grouped question instead of one query plus four filtered ones: from 71,617 to 42,130 input tokens, &lt;strong&gt;−41.2%&lt;/strong&gt;, same correct answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repair inside the tool, not in the agent's loop.&lt;/strong&gt; When the database rejects a generated SQL query, the SQL tool now regenerates it once with the database error, instead of sending the error back to an agent that would re-read its whole context to try again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query and chart in one call&lt;/strong&gt; for our Data Analyst agent, instead of two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch: this kind of cut only pays if the model actually uses it. &lt;code&gt;group_by&lt;/code&gt; adds 72 tokens to every call's declarations whether or not the model reaches for it. On real runs, it reached for it on 4 of 7 dashboard queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; look at how many calls a question takes before you look at how big each call is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trim the instructions, last
&lt;/h3&gt;

&lt;p&gt;Removing an instruction block that described tools the agent didn't have saved about 2%. Worth doing, because the model was being told about tools it couldn't call. But it's the smallest win on this list, so it comes last.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What didn't work, or not the way we expected
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read in someone else's post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compacting the current turn.&lt;/strong&gt; It looked like the obvious next step after shortening old results. The quality checks on facts and grounding &lt;strong&gt;failed on 4 scenarios&lt;/strong&gt;. Within a turn, the model is still working on its results; take them away and it starts answering from memory. Without the bench, we'd have shipped it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the bench too much.&lt;/strong&gt; Our scripted replay overestimated real totals by 9 to 16%, because a real model doesn't always take the same path as a script. It also underestimated one gain by a lot: −13.8% on the bench for a dashboard question, −41.2% for real, because the real model changed its behavior (one grouped query) in a way a script can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blind spot we didn't see.&lt;/strong&gt; On real runs, the forecast scenario failed: the orders file has several rows per date, and our forecast engine expects one and doesn't aggregate them. None of the cuts caused it, but the bench never saw it, because it replays a canned forecast answer instead of calling the real engine. We left the forecast numbers out of the results, and aggregating by date before forecasting is on our list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache we don't control.&lt;/strong&gt; Depending on the session, between 17% and 66% of our input was already served from the provider's implicit cache, without us doing anything. Nice when it happens, impossible to plan for. That's why stable ordering matters: it turns luck into something you can count on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix we almost made.&lt;/strong&gt; Twice in one real run, the model tried to group by a period filter the tool didn't accept for grouping. Those two failed attempts cost about 27,000 input tokens. The reflex was a stricter docstring. We did the opposite: the tool now accepts what the model reached for, and its error lists the values it does accept. When a model keeps reaching for something, that's often a spec for the tool, not a mistake to forbid.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Memory that earns its tokens
&lt;/h2&gt;

&lt;p&gt;None of this means agents should remember less. It means every token they re-read has to earn its place.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sessions live in the database,&lt;/strong&gt; so the conversation survives restarts. That was the point of &lt;a href="https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80"&gt;the first post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A window without amnesia.&lt;/strong&gt; Our data and BI agents send a window of recent messages, and the text of the turns that fall out of it is kept: the agent forgets the bulk of what it pulled, not what was said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-term memory, scoped&lt;/strong&gt; per agent and per user, so one user's memories never leak into another's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A case bank, on trial.&lt;/strong&gt; Inspired by the &lt;a href="https://arxiv.org/abs/2508.16153" rel="noopener noreferrer"&gt;Memento&lt;/a&gt; paper, each finished run becomes a case (task, tools called, outcome), and similar cases are shown before the next run. It's behind a flag, because memory isn't free either. We wrote the go/no-go down &lt;em&gt;before&lt;/em&gt; the first real measurement: at least +10 points of success rate with 4 past cases, no rise in tool errors, at most +10% input tokens per task. The results aren't in yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one guardrail ties it all together: &lt;strong&gt;a retry re-pays everything.&lt;/strong&gt; When a provider hits its quota, our whole queue pauses instead of retrying every few seconds at full input cost, and a run is capped at 25 model calls, so a loop can't spin forever on your bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Offline bench, all cuts combined&lt;/strong&gt; (input tokens, scripted model, quality checks passing on these scenarios):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard creation&lt;/td&gt;
&lt;td&gt;104,285&lt;/td&gt;
&lt;td&gt;86,925&lt;/td&gt;
&lt;td&gt;−16.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard question&lt;/td&gt;
&lt;td&gt;75,227&lt;/td&gt;
&lt;td&gt;64,811&lt;/td&gt;
&lt;td&gt;−13.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard chat&lt;/td&gt;
&lt;td&gt;151,177&lt;/td&gt;
&lt;td&gt;98,321&lt;/td&gt;
&lt;td&gt;−35.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three-question conversation&lt;/td&gt;
&lt;td&gt;286,522&lt;/td&gt;
&lt;td&gt;196,983&lt;/td&gt;
&lt;td&gt;−31.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First call&lt;/td&gt;
&lt;td&gt;17,070&lt;/td&gt;
&lt;td&gt;13,598&lt;/td&gt;
&lt;td&gt;−20.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Real runs&lt;/strong&gt; (input tokens read from our usage table, correct answers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First call floor&lt;/td&gt;
&lt;td&gt;~14,500&lt;/td&gt;
&lt;td&gt;~11,800&lt;/td&gt;
&lt;td&gt;−18.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard creation&lt;/td&gt;
&lt;td&gt;82,212&lt;/td&gt;
&lt;td&gt;63,526&lt;/td&gt;
&lt;td&gt;−22.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard question&lt;/td&gt;
&lt;td&gt;71,617&lt;/td&gt;
&lt;td&gt;42,130&lt;/td&gt;
&lt;td&gt;−41.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  6. A checklist for your own agent
&lt;/h2&gt;

&lt;p&gt;If you only take one thing from this post, run these seven checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weigh your first call.&lt;/strong&gt; How many tokens does your agent send before the user's question? If you don't know, start there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare declared tools with called tools.&lt;/strong&gt; Over your last few hundred runs, which tools were never called? Remove them from that agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at what you re-send from past turns.&lt;/strong&gt; Shorten old tool results. Never the current turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the files and images in your context.&lt;/strong&gt; Send a preview and let the agent reach the rest through tools. Send images as images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that your prefix is stable.&lt;/strong&gt; Order what you inject deterministically, set cache markers where your provider needs them, and check the cached tokens your provider actually reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count calls per question.&lt;/strong&gt; Look for loops and retries that re-pay the whole stack, and cap calls per run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a bench with quality checks before you cut,&lt;/strong&gt; and confirm every gain on real runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reproduce it, and make the bench yours
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Reproduce our measurements.&lt;/strong&gt; The bench, the scenarios and the results are in the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/apowerb/apowerb &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;apowerb
python bench/bi_tokens/replay.py &lt;span class="nt"&gt;--estimates&lt;/span&gt; &lt;span class="nt"&gt;--compare&lt;/span&gt; bench/bi_tokens/results/main.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. If you want to go further, contribute.&lt;/strong&gt; The bench only knows our agents today. You can add a scenario that looks like your traffic (one JSON file in &lt;a href="https://github.com/apowerb/apowerb/tree/main/bench/bi_tokens/scenarios" rel="noopener noreferrer"&gt;&lt;code&gt;bench/bi_tokens/scenarios&lt;/code&gt;&lt;/a&gt;), make the forecast scenario call the real engine so the bench can't miss a failure like ours again, or fix the forecast by aggregating repeated dates. Comment on GitHub before you start, so we agree on the shape. The tech team reviews every PR, and contributors are credited by name.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it locally (Docker Compose) or on Kubernetes (Helm):&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb-hosting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://docs.apowerb.com/" rel="noopener noreferrer"&gt;docs.apowerb.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try it without installing anything:&lt;/strong&gt; &lt;a href="https://apowerb.com" rel="noopener noreferrer"&gt;apowerb.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The questions we don't have good answers to yet
&lt;/h2&gt;

&lt;p&gt;This is where I'd genuinely like your experience. The last two posts got better because readers answered questions like these.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static tools or tools on demand?&lt;/strong&gt; We declare a fixed toolbox per agent. The other school loads tool definitions only when the model asks for them. That saves the declarations, but costs an extra call to find the right tool. Where does on-demand start to pay: 30 tools, 50, 100? Have you measured it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does compaction stop?&lt;/strong&gt; We never touch the current turn. But some turns pile up ten tool results before the model answers. Do you summarize inside a long turn, with a model that costs tokens itself, or truncate with a rule that might drop the one row that mattered?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implicit or explicit cache?&lt;/strong&gt; Between 17% and 66% of our input came from our provider's implicit cache, depending on the session. We have an explicit cache wired in but turned off by default, because it means managing lifetimes and paying to write the cache. Has an explicit cache ever paid for itself in your agents, and at what traffic?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much should a cached token count?&lt;/strong&gt; We weight cached input at 25% in our quotas. But providers price it differently, and some charge a premium to write the cache, which we don't track yet. How do you bill or budget cached tokens for your users?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you build a bench that can't lie?&lt;/strong&gt; Ours replays canned tool answers to stay cheap and repeatable, and that's exactly how it missed a real failure. Do you call real engines in your bench, and how do you keep it affordable and stable?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Answer in the comments, even with a single number. The best answers will shape the next changes in the repo, credited to you.&lt;/p&gt;

&lt;p&gt;And if you only have time for one: &lt;strong&gt;how many tokens does your agent pay before it reads the user's question?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before every single thought, our BI agent re-read 17,000 tokens of instructions and tool manuals.&lt;/p&gt;

&lt;p&gt;Then the whole conversation. Then every table it had pulled since the first question. In our offline replay of a three-question conversation, ten model calls consumed &lt;strong&gt;286,522 input tokens&lt;/strong&gt; before optimization. Real runs behaved differently, as you'll see below.&lt;/p&gt;

&lt;p&gt;Nobody had decided that. It's just what an agent does by default: every model call starts from zero, so everything the agent "knows" gets sent again. Every call, every turn, every retry.&lt;/p&gt;

&lt;p&gt;This post is a field report on how we brought it down: how we measured, which cuts paid off and in what order, &lt;strong&gt;what didn't work&lt;/strong&gt;, and a checklist you can run on your own agent tomorrow. It ends with five questions we can't answer alone. The optimizations reported here are implemented, and the benchmark is open source, so you can check every number. Some experiments and benchmark improvements are still in progress, and I'll say which.&lt;/p&gt;

&lt;p&gt;It's the third post in a series about what breaks when AI agents run in production, after &lt;a href="https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80"&gt;agents that forgot everything after a restart&lt;/a&gt; and &lt;a href="https://dev.to/anis_meziani_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap"&gt;agents that redid work they had already done&lt;/a&gt;. This one is about the opposite of amnesia: agents that remember too much, and pay for it on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: six lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure before you cut.&lt;/strong&gt; Without a bench and quality checks, our best-looking cut would have quietly broken the answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with the tools.&lt;/strong&gt; Instructions plus 36 tool declarations cost 17,000 tokens before the question. Removing the tools the agent never calls was the cheapest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forget the past, never the present.&lt;/strong&gt; Shortening old tool results saved 21 to 26%. Shortening the current turn broke quality on 4 scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable prompts are cacheable prompts.&lt;/strong&gt; In our benchmark, ordering the schema tables deterministically raised the reusable prefix of a SQL prompt from 11% to 98%, with the same tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The biggest real gain came from fewer calls, not smaller ones:&lt;/strong&gt; −41% on a question, because the model asked once instead of five times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your bench will lie to you a little.&lt;/strong&gt; Ours overestimated real gains by 9 to 16%, underestimated one by a lot, and missed a failure entirely.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. Measure before you cut
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Know what you're paying for
&lt;/h3&gt;

&lt;p&gt;Here's what our BI agent sent to the model on every call, before we changed anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Paid when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instructions&lt;/td&gt;
&lt;td&gt;the agent's system prompt&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool declarations&lt;/td&gt;
&lt;td&gt;name, description and schema of each of 36 tools&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;every message of the conversation&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Past tool results&lt;/td&gt;
&lt;td&gt;every table, file and query result pulled so far&lt;/td&gt;
&lt;td&gt;every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current turn&lt;/td&gt;
&lt;td&gt;the question, and the results the model is working on now&lt;/td&gt;
&lt;td&gt;every call of this turn&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two layers alone came to about &lt;strong&gt;17,000 tokens&lt;/strong&gt;: the whole first call weighed 17,070, a one-line question included. The model paid them before reading a word of the question, and again at each of the 3 to 5 calls a question takes.&lt;/p&gt;

&lt;p&gt;We're not alone. The industry now calls it the "MCP tax": &lt;a href="https://getunblocked.com/blog/mcp-token-budget-autopsy/index.md" rel="noopener noreferrer"&gt;one measurement published in May&lt;/a&gt; found around 71,000 tokens of overhead for seven MCP servers, about 35% of a 200K context window, before the first prompt. And the cost isn't only money: research on &lt;a href="https://morphllm.com/context-rot" rel="noopener noreferrer"&gt;context rot&lt;/a&gt; keeps showing that models get worse as their input grows, well before the window is full. A useless token is paid twice, on the bill and in the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build the bench first
&lt;/h3&gt;

&lt;p&gt;We built a bench before touching anything. It lives in &lt;a href="https://github.com/apowerb/apowerb/tree/main/bench/bi_tokens" rel="noopener noreferrer"&gt;&lt;code&gt;bench/bi_tokens&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five scenarios&lt;/strong&gt; of our BI agent: creating a dashboard, asking a question about it, chatting with it, a three-question conversation, and a forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An offline replay&lt;/strong&gt; with a scripted model, so each change is compared on identical calls, input tokens counted call by call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality checks&lt;/strong&gt; on every scenario: the right facts, grounded in the data. A cut that saves tokens but fails a check is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real runs&lt;/strong&gt; afterwards, with costs read from the usage table where every model call is recorded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters more than it sounds. Section 3 explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The cuts, ranked by effort and gain
&lt;/h2&gt;

&lt;p&gt;Here's what we did, in the order we'd do it again:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cut&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;Measured gain&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remove the tools the agent never calls&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;−10 to −15% per scenario, first call −18.5% on real runs&lt;/td&gt;
&lt;td&gt;nothing observed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shorten old tool results, past turns only&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;−21 to −26% on conversations&lt;/td&gt;
&lt;td&gt;never touch the current turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Describe big payloads instead of pasting them&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;base64 page images no longer re-sent at every turn&lt;/td&gt;
&lt;td&gt;the agent must still reach the full data through tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make the prompt prefix cache-friendly&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;reusable prefix 11% → 98% (benchmark, upper bound)&lt;/td&gt;
&lt;td&gt;depends on your provider and on which tables are selected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fewer round trips&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;−41% on a question, real run&lt;/td&gt;
&lt;td&gt;only pays if the model uses it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trim the instructions&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;about −2%&lt;/td&gt;
&lt;td&gt;small, do it last&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Remove the tools the agent never calls
&lt;/h3&gt;

&lt;p&gt;Our BI templates had inherited the generic chat tools of the platform, the ones a general assistant needs and a BI agent never calls. Most agent stacks ship this way; we just measured ours.&lt;/p&gt;

&lt;p&gt;We removed ten of them from the BI templates, and trimmed what remained: declarations lost their pydantic titles and "Returns" sections, and tool responses are capped. 36 tools down to 26, and the first call went from 17,070 to 13,598 tokens on the bench. On real runs, together with the instruction trim described below, it went from about 14,500 to 11,800.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; an agent's toolbox is a cost line. Declare tools per agent, not per platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shorten old tool results, never the current turn
&lt;/h3&gt;

&lt;p&gt;After a few questions, most of the context was old tool results: tables the agent had pulled two turns ago and already answered from. Now, in every turn &lt;em&gt;before&lt;/em&gt; the current one, a large tool result keeps its small fields (ids, titles, status, a message) and loses its bulk, with a note telling the model it can call the tool again if it needs the data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compact_previous_turns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The current turn starts at the last user message that isn't a tool result.
&lt;/span&gt;    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_turn_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;          &lt;span class="c1"&gt;# earlier turns only
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_large_tool_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keep_small_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ids, titles, status
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs on a copy: the stored session keeps everything, only what the model re-reads is shortened. And the answer the model gave at the time stays in the history, so what it concluded isn't lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; storing everything is cheap. Re-sending everything is what costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Describe big payloads instead of pasting them
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Big files.&lt;/strong&gt; A large CSV used to be pasted into the context. Now the agent gets the header, the first 20 rows and the row count, and works on the full data through its tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images.&lt;/strong&gt; In our pipeline, a base64-encoded PDF page was estimated at roughly 100,000 to 150,000 text tokens, depending on the page and its encoding (an estimate written down in our code when we hit it, not a formal benchmark), and it used to be re-sent at every later turn. Now images go to the model as images, and base64 is stripped from older turns. The model saw the page once; its own description of it is the source of truth after that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; the model needs to know what's in a file, not to hold the file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the prompt prefix cache-friendly
&lt;/h3&gt;

&lt;p&gt;Providers cache prompt prefixes, but only if the beginning of your request is &lt;em&gt;exactly&lt;/em&gt; the same as before. Our text-to-SQL tool injected the relevant tables of the schema in order of relevance. Relevance changes with every question, so the prefix did too.&lt;/p&gt;

&lt;p&gt;We now write the selected tables in name order: same tables, same tokens, only the order changes. To measure the effect, our benchmark sends 10 fixed questions against a fixed schema, and counts, for questions 2 to 10, how much of each request matches the previous request's prefix, using OpenAI's caching rules (prefixes of at least 1,024 tokens, in 128-token blocks):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Reusable prefix, relevance order&lt;/th&gt;
&lt;th&gt;Reusable prefix, name order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25 tables, all selected every time&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;98%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;80 tables, 62 or 63 selected per question&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things this number is not. It's not the share actually served from the cache: that depends on the provider routing your requests to the same cache, and shows up in your usage data. It's not a cost reduction either. And ordering alone doesn't guarantee a stable prefix: it works best when the same tables come back from one question to the next, which is why the second row is lower.&lt;/p&gt;

&lt;p&gt;Two more things we learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some providers cache nothing unless you ask.&lt;/strong&gt; On Claude models, nothing was cached until we set cache markers: every call of a turn paid the instructions and tool declarations, 5,000 to 17,000 tokens depending on the agent, at the full rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count cached tokens for what they cost.&lt;/strong&gt; Our usage quotas counted cached input at full price. They now count it at 25%, otherwise users are charged for savings already made.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; keep the stable parts of your prompt stable. When your provider supports prompt caching, deterministic ordering can turn repeated context into reusable context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fewer round trips
&lt;/h3&gt;

&lt;p&gt;Every extra model call re-pays the whole stack. So the cheapest call is the one that doesn't happen.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One grouped query instead of five.&lt;/strong&gt; We gave the dashboard query tool a &lt;code&gt;group_by&lt;/code&gt; parameter. On a real run, the model asked one grouped question instead of one query plus four filtered ones: from 71,617 to 42,130 input tokens, &lt;strong&gt;−41.2%&lt;/strong&gt;, same correct answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repair inside the tool, not in the agent's loop.&lt;/strong&gt; When the database rejects a generated SQL query, the SQL tool now regenerates it once with the database error, instead of sending the error back to an agent that would re-read its whole context to try again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query and chart in one call&lt;/strong&gt; for our Data Analyst agent, instead of two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch: this kind of cut only pays if the model actually uses it. &lt;code&gt;group_by&lt;/code&gt; adds 72 tokens to every call's declarations whether or not the model reaches for it. On real runs, it reached for it on 4 of 7 dashboard queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; look at how many calls a question takes before you look at how big each call is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trim the instructions, last
&lt;/h3&gt;

&lt;p&gt;Removing an instruction block that described tools the agent didn't have saved about 2%. Worth doing, because the model was being told about tools it couldn't call. But it's the smallest win on this list, so it comes last.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What didn't work, or not the way we expected
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read in someone else's post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compacting the current turn.&lt;/strong&gt; It looked like the obvious next step after shortening old results. The quality checks on facts and grounding &lt;strong&gt;failed on 4 scenarios&lt;/strong&gt;. Within a turn, the model is still working on its results; take them away and it starts answering from memory. Without the bench, we'd have shipped it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the bench too much.&lt;/strong&gt; Our scripted replay overestimated real totals by 9 to 16%, because a real model doesn't always take the same path as a script. It also underestimated one gain by a lot: −13.8% on the bench for a dashboard question, −41.2% for real, because the real model changed its behavior (one grouped query) in a way a script can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blind spot we didn't see.&lt;/strong&gt; On real runs, the forecast scenario failed: the orders file has several rows per date, and our forecast engine expects one and doesn't aggregate them. None of the cuts caused it, but the bench never saw it, because it replays a canned forecast answer instead of calling the real engine. We left the forecast numbers out of the results, and aggregating by date before forecasting is on our list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache we don't control.&lt;/strong&gt; Depending on the session, between 17% and 66% of our input was already served from the provider's implicit cache, without us doing anything. Nice when it happens, impossible to plan for. That's why stable ordering matters: it turns luck into something you can count on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix we almost made.&lt;/strong&gt; Twice in one real run, the model tried to group by a period filter the tool didn't accept for grouping. Those two failed attempts cost about 27,000 input tokens. The reflex was a stricter docstring. We did the opposite: the tool now accepts what the model reached for, and its error lists the values it does accept. When a model keeps reaching for something, that's often a spec for the tool, not a mistake to forbid.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Memory that earns its tokens
&lt;/h2&gt;

&lt;p&gt;None of this means agents should remember less. It means every token they re-read has to earn its place.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sessions live in the database,&lt;/strong&gt; so the conversation survives restarts. That was the point of &lt;a href="https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80"&gt;the first post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A window without amnesia.&lt;/strong&gt; Our data and BI agents send a window of recent messages, and the text of the turns that fall out of it is kept: the agent forgets the bulk of what it pulled, not what was said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-term memory, scoped&lt;/strong&gt; per agent and per user, so one user's memories never leak into another's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A case bank, on trial.&lt;/strong&gt; Inspired by the &lt;a href="https://arxiv.org/abs/2508.16153" rel="noopener noreferrer"&gt;Memento&lt;/a&gt; paper, each finished run becomes a case (task, tools called, outcome), and similar cases are shown before the next run. It's behind a flag, because memory isn't free either. We wrote the go/no-go down &lt;em&gt;before&lt;/em&gt; the first real measurement: at least +10 points of success rate with 4 past cases, no rise in tool errors, at most +10% input tokens per task. The results aren't in yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one guardrail ties it all together: &lt;strong&gt;a retry re-pays everything.&lt;/strong&gt; When a provider hits its quota, our whole queue pauses instead of retrying every few seconds at full input cost, and a run is capped at 25 model calls, so a loop can't spin forever on your bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Offline bench, all cuts combined&lt;/strong&gt; (input tokens, scripted model, quality checks passing on these scenarios):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard creation&lt;/td&gt;
&lt;td&gt;104,285&lt;/td&gt;
&lt;td&gt;86,925&lt;/td&gt;
&lt;td&gt;−16.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard question&lt;/td&gt;
&lt;td&gt;75,227&lt;/td&gt;
&lt;td&gt;64,811&lt;/td&gt;
&lt;td&gt;−13.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard chat&lt;/td&gt;
&lt;td&gt;151,177&lt;/td&gt;
&lt;td&gt;98,321&lt;/td&gt;
&lt;td&gt;−35.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three-question conversation&lt;/td&gt;
&lt;td&gt;286,522&lt;/td&gt;
&lt;td&gt;196,983&lt;/td&gt;
&lt;td&gt;−31.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First call&lt;/td&gt;
&lt;td&gt;17,070&lt;/td&gt;
&lt;td&gt;13,598&lt;/td&gt;
&lt;td&gt;−20.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Real runs&lt;/strong&gt; (input tokens read from our usage table, correct answers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First call floor&lt;/td&gt;
&lt;td&gt;~14,500&lt;/td&gt;
&lt;td&gt;~11,800&lt;/td&gt;
&lt;td&gt;−18.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard creation&lt;/td&gt;
&lt;td&gt;82,212&lt;/td&gt;
&lt;td&gt;63,526&lt;/td&gt;
&lt;td&gt;−22.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard question&lt;/td&gt;
&lt;td&gt;71,617&lt;/td&gt;
&lt;td&gt;42,130&lt;/td&gt;
&lt;td&gt;−41.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  6. A checklist for your own agent
&lt;/h2&gt;

&lt;p&gt;If you only take one thing from this post, run these seven checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weigh your first call.&lt;/strong&gt; How many tokens does your agent send before the user's question? If you don't know, start there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare declared tools with called tools.&lt;/strong&gt; Over your last few hundred runs, which tools were never called? Remove them from that agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at what you re-send from past turns.&lt;/strong&gt; Shorten old tool results. Never the current turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the files and images in your context.&lt;/strong&gt; Send a preview and let the agent reach the rest through tools. Send images as images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that your prefix is stable.&lt;/strong&gt; Order what you inject deterministically, set cache markers where your provider needs them, and check the cached tokens your provider actually reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count calls per question.&lt;/strong&gt; Look for loops and retries that re-pay the whole stack, and cap calls per run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a bench with quality checks before you cut,&lt;/strong&gt; and confirm every gain on real runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The questions we don't have good answers to yet
&lt;/h2&gt;

&lt;p&gt;This is where I'd genuinely like your experience. The last two posts got better because readers answered questions like these.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static tools or tools on demand?&lt;/strong&gt; We declare a fixed toolbox per agent. The other school loads tool definitions only when the model asks for them. That saves the declarations, but costs an extra call to find the right tool. Where does on-demand start to pay: 30 tools, 50, 100? Have you measured it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does compaction stop?&lt;/strong&gt; We never touch the current turn. But some turns pile up ten tool results before the model answers. Do you summarize inside a long turn, with a model that costs tokens itself, or truncate with a rule that might drop the one row that mattered?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implicit or explicit cache?&lt;/strong&gt; Between 17% and 66% of our input came from our provider's implicit cache, depending on the session. We have an explicit cache wired in but turned off by default, because it means managing lifetimes and paying to write the cache. Has an explicit cache ever paid for itself in your agents, and at what traffic?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much should a cached token count?&lt;/strong&gt; We weight cached input at 25% in our quotas. But providers price it differently, and some charge a premium to write the cache, which we don't track yet. How do you bill or budget cached tokens for your users?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you build a bench that can't lie?&lt;/strong&gt; Ours replays canned tool answers to stay cheap and repeatable, and that's exactly how it missed a real failure. Do you call real engines in your bench, and how do you keep it affordable and stable?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Answer in the comments, even with a single number. The best answers will shape the next changes in the repo, credited to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it, then tell us your numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Reproduce our measurements.&lt;/strong&gt; The bench, the scenarios and the results are in the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/apowerb/apowerb &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;apowerb
python bench/bi_tokens/replay.py &lt;span class="nt"&gt;--estimates&lt;/span&gt; &lt;span class="nt"&gt;--compare&lt;/span&gt; bench/bi_tokens/results/main.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Share your own numbers in the comments.&lt;/strong&gt; Run the checklist on your agent and post what you find, even just the first one: how many tokens it pays before the user's question. The last two posts got better because readers answered, and I'd bet this one will too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. If you want to go further, contribute.&lt;/strong&gt; The bench only knows our agents today. You can add a scenario that looks like your traffic (one JSON file in &lt;a href="https://github.com/apowerb/apowerb/tree/main/bench/bi_tokens/scenarios" rel="noopener noreferrer"&gt;&lt;code&gt;bench/bi_tokens/scenarios&lt;/code&gt;&lt;/a&gt;), make the forecast scenario call the real engine so the bench can't miss a failure like ours again, or fix the forecast by aggregating repeated dates. Comment on GitHub before you start, so we agree on the shape. The tech team reviews every PR, and contributors are credited by name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many tokens does your agent pay before it reads the user's question?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Code : &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;https://github.com/apowerb/apowerb&lt;/a&gt;&lt;br&gt;
Docs : &lt;a href="https://docs.apowerb.com/" rel="noopener noreferrer"&gt;https://docs.apowerb.com/&lt;/a&gt; &lt;br&gt;
Deploy with Docker Compose or Helm : &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;https://github.com/apowerb/apowerb-hosting&lt;/a&gt;&lt;br&gt;
Try it out : &lt;a href="https://apowerb.com" rel="noopener noreferrer"&gt;https://apowerb.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>Idempotency keys for AI agents: why hashing the arguments doesn't work</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:00:45 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/idempotency-keys-for-ai-agents-why-hashing-the-arguments-doesnt-work-cap</guid>
      <description>&lt;p&gt;After our last post on &lt;a href="https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80"&gt;why our agents refuse to redo work after a crash&lt;/a&gt;, a reader left a comment that took the discussion a level deeper.&lt;/p&gt;

&lt;p&gt;The point, roughly: you can't cleanly resume a non-deterministic trajectory, so the only thing that really holds is &lt;strong&gt;idempotency at the effect boundary.&lt;/strong&gt; And then the question that stuck with me: &lt;em&gt;why make the orchestrator decide what's safe to replay, instead of pushing that decision down to the tools themselves?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;They're right, and this post is my attempt to answer properly. It covers what we do today, why the obvious version of that idea breaks with LLMs, and the design we're converging on. Fair warning: the last part isn't shipped. It's a proposal, and I'd genuinely like it torn apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Today, &lt;a href="https://www.thaink2.com/apowerb" rel="noopener noreferrer"&gt;ApowerB&lt;/a&gt; refuses to auto-replay any run that called &lt;strong&gt;any&lt;/strong&gt; tool, reads included. Safe, blunt, and stuck the moment a run completes halfway.&lt;/li&gt;
&lt;li&gt;The fix is per-tool idempotency keys, so a replay dedupes at the tool. But &lt;strong&gt;hashing the arguments doesn't work&lt;/strong&gt; when an LLM writes them: the replay drafts a slightly different email, and the hash changes.&lt;/li&gt;
&lt;li&gt;The rule that holds: &lt;strong&gt;build the key from stable intent, never from generated content.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;We already do this in two places, and enforce "read-only" by construction in a few others. The rest is a design sketch, and an open invitation.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where we are today: default-deny
&lt;/h2&gt;

&lt;p&gt;When a run fails, our runtime records which tools it called. If it called &lt;strong&gt;anything&lt;/strong&gt;, an automatic replay is refused with a &lt;code&gt;409&lt;/code&gt; that lists the tools (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/run_main.py" rel="noopener noreferrer"&gt;&lt;code&gt;run_main.py&lt;/code&gt;&lt;/a&gt;). A human can still force it.&lt;/p&gt;

&lt;p&gt;We don't classify tools. A &lt;code&gt;GET&lt;/code&gt; counts the same as a &lt;code&gt;send_email&lt;/code&gt;. That sounds lazy, so let me defend it for a second, because the reader's own examples are the best argument for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;GET&lt;/code&gt; against a rate-limited or stateful API is not free to repeat;&lt;/li&gt;
&lt;li&gt;a "pure" lookup that writes an audit row isn't pure;&lt;/li&gt;
&lt;li&gt;a tool from a third-party MCP server can call itself read-only, and you have no way to check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inferring purity gets exactly these cases wrong, and they're the ones that hurt. Refusing everything costs a click. Guessing wrong costs a duplicate email to a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where default-deny breaks
&lt;/h2&gt;

&lt;p&gt;Default-deny is safe. It is also &lt;strong&gt;stuck&lt;/strong&gt; the moment a run finishes halfway.&lt;/p&gt;

&lt;p&gt;Picture an email campaign. Our &lt;a href="https://github.com/apowerb/apowerb/tree/main/src/apowerb/skills_store/portfolio/email-campaign" rel="noopener noreferrer"&gt;&lt;code&gt;email-campaign&lt;/code&gt; skill&lt;/a&gt; already puts a human gate in front of the effect: it drafts everything, then &lt;strong&gt;never sends without explicit approval&lt;/strong&gt;. Once approved, it loops over the recipients and sends one email each.&lt;/p&gt;

&lt;p&gt;Now the pod dies after email 40 of 100.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replay is refused, because tools were called. Correct, and useless: 60 people never get their email.&lt;/li&gt;
&lt;li&gt;Force the replay, and 40 people get it twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no good answer at the run level. The run is the wrong unit. &lt;strong&gt;The email is the unit.&lt;/strong&gt; That's exactly where the reader's idea comes in: let each effect know whether it already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious version, and why it breaks
&lt;/h2&gt;

&lt;p&gt;The textbook approach: every effectful tool computes an idempotency key from its arguments, say &lt;code&gt;sha256(json(args))&lt;/code&gt;, and the runtime skips any call whose key it has already executed.&lt;/p&gt;

&lt;p&gt;With a normal program, that works. With an LLM it quietly fails, because &lt;strong&gt;the arguments aren't stable either.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First attempt, the model calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anna@acme.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Following up on your request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hi Anna, following up on the quote you asked for..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay, same input, same agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anna@acme.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your quote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello Anna, I'm getting back to you about your quote..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different subject, different body, different hash. The ledger has never seen this key. Anna gets two emails, and your idempotency layer reports that everything went perfectly.&lt;/p&gt;

&lt;p&gt;The reader's line from the last thread applies one level down: &lt;em&gt;step 3 isn't a stable coordinate&lt;/em&gt;. &lt;strong&gt;Neither are the arguments.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: stable intent, never generated content
&lt;/h2&gt;

&lt;p&gt;Split every argument into three kinds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;In the key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Identity from the outside world&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;event id, message id, thread id, record id, normalized recipient address&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Derived deterministically from inputs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;hash of an uploaded file's content&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Generated by the model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;body, subject, summary, any free text&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Never&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key answers &lt;em&gt;"have I already done this thing, for this reason, to this target?"&lt;/em&gt;, not &lt;em&gt;"have I already sent these exact bytes?"&lt;/em&gt;. For the campaign: &lt;code&gt;key = (triggering_event, tool, recipient)&lt;/code&gt;. The replay can rewrite every email from scratch; Anna's key is the same, so she's skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two places where we already do this
&lt;/h2&gt;

&lt;p&gt;This isn't hypothetical in our codebase. It's just not generalized yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Webhooks: the key is the provider's identity.&lt;/strong&gt; When Microsoft Graph re-delivers a notification, a unique index on &lt;code&gt;(subscription_id, resource_id)&lt;/code&gt; rejects the duplicate insert, and the message is processed once (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/routers/webhook_handlers/outlook.py" rel="noopener noreferrer"&gt;&lt;code&gt;outlook.py&lt;/code&gt;&lt;/a&gt;). Nothing generated goes into that key. It's pure outside-world identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. BI datasets: the key is derived from the content.&lt;/strong&gt; When an agent imports a file as a dataset, the dataset's ID isn't random. It's a UUIDv5 over who owns it, where it lives, and a SHA-256 of the bytes (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/tools_store/portfolio/bi_datasets.py" rel="noopener noreferrer"&gt;&lt;code&gt;bi_datasets.py&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;dataset_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NAMESPACE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;folder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
              &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sheet&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dataset_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dataset_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dataset_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it twice on the same file: one dataset. Change one byte: a new one. Notice what's &lt;em&gt;not&lt;/em&gt; in the key: the display name, which is formatted text.&lt;/p&gt;

&lt;p&gt;And here's the scar that comes free with it. What if the user &lt;strong&gt;deleted&lt;/strong&gt; that dataset? Soft deletes keep the row, so the deterministic ID is still taken, and the insert collides. Our code catches the &lt;code&gt;IntegrityError&lt;/code&gt; and falls back to a fresh random ID. That's the general lesson: &lt;strong&gt;an idempotency key outlives the thing it points to.&lt;/strong&gt; Your ledger needs to know the difference between "already done" and "done, then undone".&lt;/p&gt;

&lt;h2&gt;
  
  
  And some tools are pure by construction, not by declaration
&lt;/h2&gt;

&lt;p&gt;The other half of the reader's question was classification. Our answer so far: we don't trust a tool that &lt;em&gt;says&lt;/em&gt; it's read-only. We trust one that &lt;em&gt;can't&lt;/em&gt; write.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Oracle tool opens a &lt;code&gt;SET TRANSACTION READ ONLY&lt;/code&gt; and rolls back (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/tools_store/portfolio/oracle.py" rel="noopener noreferrer"&gt;&lt;code&gt;oracle.py&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;The MongoDB tool refuses aggregation pipelines with a write stage (&lt;code&gt;$out&lt;/code&gt;, &lt;code&gt;$merge&lt;/code&gt;) (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/tools_store/portfolio/mongodb.py" rel="noopener noreferrer"&gt;&lt;code&gt;mongodb.py&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;The Redshift tool validates that the SQL is read-only before it runs (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/tools_store/portfolio/redshift.py" rel="noopener noreferrer"&gt;&lt;code&gt;redshift.py&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the only tools we'd be comfortable marking "safe to replay", and only because the guarantee lives in the tool's code, not in a flag next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design we're converging on
&lt;/h2&gt;

&lt;p&gt;This part is a &lt;strong&gt;sketch, not shipped code.&lt;/strong&gt; It's the shape we think is right, written down so it can be challenged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Tools declare their effect, and default to the worst case.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# pure by construction, enforced in the tool
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;oracle_run_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idempotent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trigger_event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;                            &lt;span class="c1"&gt;# no declaration: treated as unsafe
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;third_party_mcp_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. An effects ledger, with a unique constraint.&lt;/strong&gt; Before an &lt;code&gt;idempotent&lt;/code&gt; tool runs, the runtime looks its key up in a ledger table. Already there: return the stored result, don't execute. Not there: execute, then record. The same unique-index trick as our webhooks, applied to every effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Undeclared stays default-deny.&lt;/strong&gt; Anything without a declaration, including every third-party tool, keeps today's behavior: if it ran, the run isn't auto-replayed. Declaring a tool &lt;code&gt;idempotent&lt;/code&gt; is how you earn automatic replay, one tool at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Pass the key through when the provider supports it.&lt;/strong&gt; Some APIs accept an idempotency key natively (Stripe's &lt;code&gt;Idempotency-Key&lt;/code&gt; header is the classic example). When they do, the key should travel all the way to the provider, so even a crash &lt;em&gt;between&lt;/em&gt; the API call and our ledger write dedupes on their side.&lt;/p&gt;

&lt;p&gt;With that in place, the campaign that died at email 40 replays cleanly: 40 ledger hits, 60 sends, nobody gets two emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions I don't have good answers to yet
&lt;/h2&gt;

&lt;p&gt;This is where I'd genuinely like help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which fields go in the key, per tool?&lt;/strong&gt; For email, &lt;code&gt;(event, recipient)&lt;/code&gt; feels right. For "post a summary to Slack", is it &lt;code&gt;(event, channel)&lt;/code&gt;? What about an agent that legitimately sends two different emails to the same person for the same event?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What if the replay takes a different branch?&lt;/strong&gt; The model may decide to email a different set of people the second time. Per-target keys handle overlap, but what about the people who were emailed in run one and dropped in run two?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long does the ledger remember?&lt;/strong&gt; Forever is wrong (storage, and "the same email next month" is legitimate). Too short reopens the duplicate window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The crash between the effect and the ledger write.&lt;/strong&gt; If the provider has no native idempotency, there's a gap. Is "record intent before, confirm after" plus reconciliation good enough?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Come build it with us
&lt;/h2&gt;

&lt;p&gt;If you've shipped something like this, or you have strong opinions about any of the questions above, we'd love your help. We're opening an RFC issue on tool-level idempotency keys, and every contributor is credited by name.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issues and RFCs:&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb/issues" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb/issues&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it locally (Docker Compose) or on Kubernetes (Helm chart):&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb-hosting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm chart on Artifact Hub:&lt;/strong&gt; &lt;a href="https://artifacthub.io/packages/helm/apowerb/apowerb-chart" rel="noopener noreferrer"&gt;artifacthub.io/packages/helm/apowerb/apowerb-chart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://docs.apowerb.com/" rel="noopener noreferrer"&gt;docs.apowerb.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product page:&lt;/strong&gt; &lt;a href="https://thaink2.com/apowerb" rel="noopener noreferrer"&gt;thaink2.com/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And thanks to the reader whose comment started all of this. The best part of writing in public is when the comments take the topic somewhere new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you build idempotency keys for your agent's tools?&lt;/strong&gt; Hash the args, stable fields, something else? Tell me in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:53:50 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/-53ko</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/-53ko</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/david_elomgnaglo_27fb318/running-adk-agents-with-mcp-tools-from-a-rest-api-nc7" class="crayons-story__hidden-navigation-link"&gt;Running ADK agents with MCP tools from a REST API&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/david_elomgnaglo_27fb318" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1980124%2F73fe5e67-dd05-4f88-a695-5ae0dfa07fa7.jpg" alt="david_elomgnaglo_27fb318 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/david_elomgnaglo_27fb318" class="crayons-story__secondary fw-medium m:hidden"&gt;
              David Elom GNAGLO
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                David Elom GNAGLO
                
                
              
              &lt;div id="story-author-preview-content-4849062" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/david_elomgnaglo_27fb318" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1980124%2F73fe5e67-dd05-4f88-a695-5ae0dfa07fa7.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;David Elom GNAGLO&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/david_elomgnaglo_27fb318/running-adk-agents-with-mcp-tools-from-a-rest-api-nc7" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 14&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/david_elomgnaglo_27fb318/running-adk-agents-with-mcp-tools-from-a-rest-api-nc7" id="article-link-4849062"&gt;
          Running ADK agents with MCP tools from a REST API
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/python"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;python&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/agents"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;agents&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/opensource"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;opensource&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/david_elomgnaglo_27fb318/running-adk-agents-with-mcp-tools-from-a-rest-api-nc7" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;16&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/david_elomgnaglo_27fb318/running-adk-agents-with-mcp-tools-from-a-rest-api-nc7#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              5&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>agents</category>
      <category>ai</category>
      <category>api</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Amnesia and déjà vu: how our AI agents failed after every restart (and how we fixed it)</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:14:02 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/amnesia-and-deja-vu-how-our-ai-agents-failed-after-every-restart-and-how-we-fixed-it-5c80</guid>
      <description>&lt;p&gt;An agent that crashes is annoying.&lt;/p&gt;

&lt;p&gt;An agent that crashes and &lt;strong&gt;comes back&lt;/strong&gt; is where things get dangerous.&lt;/p&gt;

&lt;p&gt;When we started running our agents in production, we discovered that a restart could go wrong in two completely opposite ways. Sometimes the agent came back with &lt;strong&gt;amnesia&lt;/strong&gt;: it had forgotten everything, the conversation, the context, what it was in the middle of doing. And sometimes, when we tried to fix that by simply relaunching the work, it came back with &lt;strong&gt;déjà vu&lt;/strong&gt;: it happily redid everything from the start, including the things it had already done in the real world.&lt;/p&gt;

&lt;p&gt;Like sending an email. Twice.&lt;/p&gt;

&lt;p&gt;This post is about both failures, why the obvious fixes don't work, and what we ended up building. It's the part of an agent nobody puts in a demo, and it's the part that decides whether you can trust it.&lt;/p&gt;

&lt;p&gt;Everything below comes from &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;ApowerB&lt;/a&gt;, the open-source agent runtime we build at &lt;a href="https://thaink2.com/apowerb" rel="noopener noreferrer"&gt;thaink²&lt;/a&gt;, and every fix links to the real code. If you're new to the project, &lt;a href="https://dev.to/anis_meziani_52aab42304a8/your-agent-framework-stops-at-the-demo-this-open-source-stack-ships-to-production-3ogd"&gt;this overview of the architecture&lt;/a&gt; is a good place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amnesia:&lt;/strong&gt; by default, our agent framework kept every conversation in RAM. Every restart erased everything. We moved the agent's memory into Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Déjà vu:&lt;/strong&gt; "just relaunch it" is a trap. An agent acts on the world through tools. Relaunching blindly repeats those actions.&lt;/li&gt;
&lt;li&gt;You can't cleanly "resume from step 3" with an LLM agent. So we stopped asking &lt;em&gt;how do we resume?&lt;/em&gt; and started asking &lt;strong&gt;&lt;em&gt;is it safe to start again?&lt;/em&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Every run is now written down when it starts, with its input. Every tool it calls is recorded. A failed run can be replayed without a second thought only if we &lt;strong&gt;know&lt;/strong&gt; it touched nothing. Otherwise the replay is refused, loudly, with the list of what it already did, until a human decides.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Part 1: Amnesia
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Eight restarts a day, zero memory
&lt;/h2&gt;

&lt;p&gt;At some point we ran an internal audit on one of our deployments, and one number jumped out: &lt;strong&gt;around eight restarts a day.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's not unusual. Every deploy restarts the app. Kubernetes moves pods around. A node gets drained. In a healthy cluster, processes come and go all the time.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;was&lt;/em&gt; a problem: after each of those restarts, &lt;strong&gt;every conversation was gone.&lt;/strong&gt; No history, no context. A user could be halfway through a task with an agent, and the next message would land on an agent that had never heard of them.&lt;/p&gt;

&lt;p&gt;The cause was a default. We build on Google's Agent Development Kit (ADK). Each agent conversation lives in a &lt;strong&gt;session&lt;/strong&gt;: the list of events that happened (what the user said, what the model answered, which tools it called, what came back). That session &lt;em&gt;is&lt;/em&gt; the agent's short-term memory. And if you don't configure where sessions live, ADK falls back to an &lt;strong&gt;in-memory session service.&lt;/strong&gt; A Python dict, basically. It works perfectly on your laptop, and it evaporates every time the process restarts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tab that killed the workflow
&lt;/h2&gt;

&lt;p&gt;The second kind of amnesia was even sillier.&lt;/p&gt;

&lt;p&gt;Our visual workflow builder (you chain agents on a canvas, and each one passes its output to the next) originally ran &lt;strong&gt;in the browser.&lt;/strong&gt; The browser was the orchestrator. Which meant that if you closed the tab in the middle of a long workflow, the workflow just... stopped. Halfway. With no record of where it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runs that left nothing behind
&lt;/h2&gt;

&lt;p&gt;And the third one was the most fundamental.&lt;/p&gt;

&lt;p&gt;Only one path in the whole product could retry a failed run: incoming emails, because the webhook queue kept the payload and an attempt counter. Everything else (a workflow launched from the canvas, a scheduled agent, an API call) &lt;strong&gt;left nothing behind when it failed.&lt;/strong&gt; The workflow router literally kept its state in a Python dict that was emptied at the end of the stream.&lt;/p&gt;

&lt;p&gt;So when something failed, we couldn't relaunch it, because we didn't know what it had been asked to do. We had traces, logs, token counts. We could &lt;em&gt;see&lt;/em&gt; the failure in detail. We just couldn't &lt;em&gt;redo&lt;/em&gt; the work.&lt;/p&gt;

&lt;p&gt;That was the moment it clicked for us:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What was missing wasn't observability. It was the input.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Fix 1: give the agent a memory that survives restarts
&lt;/h2&gt;

&lt;p&gt;The first fix is one setting, and it's the most important line in this post. We point ADK's session service at Postgres instead of RAM (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/main.py" rel="noopener noreferrer"&gt;see it in &lt;code&gt;main.py&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_fast_api_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;agents_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agents_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_service_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+asyncpg://...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# sessions in Postgres
&lt;/span&gt;    &lt;span class="n"&gt;memory_service_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MEMORY_SERVICE_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;# long-term memory too
&lt;/span&gt;    &lt;span class="n"&gt;session_db_kwargs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_overflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# 5 connections per worker
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool_recycle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every session and every event is a row in the database. A pod can restart ten times a day; the conversation is still there when it comes back. And as a bonus, every conversation is now &lt;strong&gt;auditable&lt;/strong&gt;, which matters a lot once real businesses run on it.&lt;/p&gt;

&lt;p&gt;We did the same for &lt;strong&gt;long-term memory&lt;/strong&gt; (what an agent remembers &lt;em&gt;across&lt;/em&gt; conversations). The default there was also in RAM, per worker, and never fed. It now lives in the application database, scoped by agent &lt;strong&gt;and&lt;/strong&gt; by user, so one user's memories never leak into another's.&lt;/p&gt;

&lt;p&gt;Two small details that only show up in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mind the connection pool.&lt;/strong&gt; Out of the box, the session engine would open up to 15 connections per worker (SQLAlchemy's default 5 plus 10 overflow). On a database shared with other services, that adds up fast. We capped it at 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory grows forever if you let it.&lt;/strong&gt; ADK's database session service has &lt;strong&gt;no retention policy&lt;/strong&gt;: the &lt;code&gt;events&lt;/code&gt; table gets one row per agent turn, each holding the full event as JSON. Left alone, it slowly eats the database. A &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/scheduler/events_retention.py" rel="noopener noreferrer"&gt;small background task&lt;/a&gt; now deletes events older than 90 days, once a day. Sessions keep their shell; they just lose old turn details.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 2: take the orchestrator out of the browser
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/workflow_engine.py" rel="noopener noreferrer"&gt;workflow engine&lt;/a&gt; moved to the server, built on ADK's own &lt;code&gt;Workflow&lt;/code&gt; primitives (chaining, fan-out, join) instead of a home-made scheduler. Every step emits &lt;code&gt;step_start&lt;/code&gt;, &lt;code&gt;step_complete&lt;/code&gt; or &lt;code&gt;step_error&lt;/code&gt;, and the run's outcome is written to the database in a &lt;code&gt;finally&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;To be precise about what changed: closing the tab still stops the run. That's deliberate. A client that disconnects cancels its run, and the cancellation is propagated to everything the workflow started (sub-workflows, retry waits), so nothing keeps running unsupervised in the background. What changed is that the run &lt;strong&gt;no longer vanishes.&lt;/strong&gt; It's recorded as &lt;code&gt;cancelled&lt;/code&gt;, with its input, and a cancelled run can be replayed. If it's safe, that is. Hold that thought until Part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: write every run down before it starts
&lt;/h2&gt;

&lt;p&gt;This is the one that made everything else possible. Every run (a workflow, a scheduled agent, a chat turn) now gets &lt;strong&gt;one row&lt;/strong&gt;, written the moment it starts. Incoming webhooks keep their own durable queue, with its own idempotency; the two will merge the day the execution engine is unified.&lt;/p&gt;

&lt;p&gt;Here's what that row holds (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/agent_store/run_store.py" rel="noopener noreferrer"&gt;&lt;code&gt;run_store.py&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_runs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;# workflow | schedule | chat | webhook
&lt;/span&gt;    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;         &lt;span class="c1"&gt;# the input. No input, no replay.
&lt;/span&gt;    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_file_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;         &lt;span class="c1"&gt;# running | success | error | cancelled
&lt;/span&gt;    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replay_of&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;# the run this one replays
&lt;/span&gt;    &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools_executed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;# more on this one below
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the run came with a file, we &lt;strong&gt;copy that file to disk&lt;/strong&gt; right away. That one came from experience: the email path already kept attachments, precisely so we could replay an email that had since been deleted from the mailbox. If you only keep a pointer to the user's file, the day it disappears, your replay is impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: memory on a diet
&lt;/h2&gt;

&lt;p&gt;Persisting memory solved amnesia. It also created a new problem: an agent that remembers &lt;em&gt;everything&lt;/em&gt; can choke on it.&lt;/p&gt;

&lt;p&gt;ADK sends the &lt;strong&gt;full conversation history&lt;/strong&gt; to the model on every turn. Now imagine a tool that converts a PDF into images so the model can read it. Each page is a base64 blob worth roughly &lt;strong&gt;100k to 150k tokens.&lt;/strong&gt; After five turns, the model has been sent the same image five times. You're paying, five times, for something it already read.&lt;/p&gt;

&lt;p&gt;So before every model call, &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/history_compaction.py" rel="noopener noreferrer"&gt;a callback&lt;/a&gt; strips large base64 payloads from &lt;strong&gt;every message except the latest one&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;before_model_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;            &lt;span class="c1"&gt;# never touch the latest turn
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
            &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function_response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;_strip_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# big base64 -&amp;gt; "&amp;lt;stripped: N chars&amp;gt;"
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model already looked at the image when it arrived. From the next turn on, &lt;strong&gt;its own text about the image is the source of truth.&lt;/strong&gt; It still sees that the tool ran and what it produced, just not the raw bytes again.&lt;/p&gt;

&lt;p&gt;Amnesia: solved. Our agents now remember. Which brings us to the second, sneakier problem.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 2: Déjà vu
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The tempting fix
&lt;/h2&gt;

&lt;p&gt;Once runs are written down with their input, the obvious next idea is: &lt;strong&gt;when a run fails, just relaunch it.&lt;/strong&gt; Same input, same agent, go again. Fully automatic. Problem solved, right?&lt;/p&gt;

&lt;p&gt;Picture an agent whose job is to process an incoming customer request. It reads the message, drafts a reply, &lt;strong&gt;sends the email&lt;/strong&gt;, then updates the CRM. Halfway through the CRM update, the pod gets evicted.&lt;/p&gt;

&lt;p&gt;Relaunch it from the start, and it reads the message, drafts a reply, and &lt;strong&gt;sends the email again.&lt;/strong&gt; The customer now has two answers. Maybe two slightly &lt;em&gt;different&lt;/em&gt; answers, because the model doesn't write the same thing twice.&lt;/p&gt;

&lt;p&gt;That's the core of the problem. An agent isn't a pure function. It &lt;strong&gt;acts on the world through its tools&lt;/strong&gt;: it sends emails, writes to databases, calls APIs. Those are side effects. And a blind relaunch repeats every one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't just "resume from step 3"
&lt;/h2&gt;

&lt;p&gt;The natural reaction is: fine, then don't restart from the beginning, resume from where it stopped. Skip the steps that are already done.&lt;/p&gt;

&lt;p&gt;With a classic workflow engine, that works, because steps are fixed and deterministic. Step 3 is always step 3.&lt;/p&gt;

&lt;p&gt;With an LLM agent, &lt;strong&gt;there is no step 3.&lt;/strong&gt; The "plan" only exists inside that particular run, in the model's reasoning at that moment. Run it again and the model may take a different path, call the tools in a different order, or decide it needs a tool it didn't use the first time. There's no clean way to replay an agent while skipping the calls it already made, because you can't replay the reasoning that led to them.&lt;/p&gt;

&lt;p&gt;So we stopped trying to answer &lt;em&gt;"how do we resume?"&lt;/em&gt; and asked a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do we know whether it's safe to start again?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To answer that, you need exactly one piece of information: &lt;strong&gt;did this run already touch the world?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: record every tool call (the call, not the answer)
&lt;/h2&gt;

&lt;p&gt;Every agent run now records the list of tools it called. We read it straight from the session events (&lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/run_main.py" rel="noopener noreferrer"&gt;&lt;code&gt;run_main.py&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;executed_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Tools called in a list of ADK events, in order.

    The call (functionCall) is what counts, not the response:
    a tool that crashes midway may have acted without ever answering.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
            &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;functionCall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function_call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That docstring hides the most important detail in this whole post. We count the &lt;strong&gt;call&lt;/strong&gt;, not the &lt;strong&gt;response&lt;/strong&gt;. If the email tool sends the email and then times out before answering, there's no response in the history. But the email is gone. If you only counted tools that answered, you'd conclude "nothing happened" and send it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 6: the memory is what makes this possible after a crash
&lt;/h2&gt;

&lt;p&gt;Here's where the two halves of this post connect.&lt;/p&gt;

&lt;p&gt;When a run fails with an exception, its response is lost. We don't get the nice list of events back. But &lt;strong&gt;the session is in Postgres now.&lt;/strong&gt; It kept everything that happened before the crash. So after a failure, we go read the session itself and extract the tool calls from it.&lt;/p&gt;

&lt;p&gt;One subtlety: a chat session contains the whole conversation, including tools called in &lt;em&gt;previous&lt;/em&gt; turns. Those aren't side effects of &lt;em&gt;this&lt;/em&gt; run. So we only look at what happened &lt;strong&gt;after the user's last message.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without Fix 1, this is impossible. Amnesia doesn't just lose the conversation, it destroys the evidence you need to decide whether relaunching is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 7: "unknown" is not "none"
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;tools_executed&lt;/code&gt; column has three possible states, and the difference between two of them is the whole safety model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[]&lt;/code&gt;: we checked, and &lt;strong&gt;no tool was called.&lt;/strong&gt; Safe to relaunch.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;["send_email", "update_crm"]&lt;/code&gt;: we checked, and these were called. Relaunching repeats them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NULL&lt;/code&gt;: we &lt;strong&gt;couldn't&lt;/strong&gt; check (the session was unreadable, for instance). We don't know.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's very tempting to treat "unknown" as "probably nothing". We treat it as "probably something", because the cost of being wrong isn't symmetric. Refusing a safe relaunch costs a click. Approving an unsafe one costs a duplicate email to a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 8: the replay rules
&lt;/h2&gt;

&lt;p&gt;With all of that in place, every replay goes through a single endpoint, &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/routers/workflows.py" rel="noopener noreferrer"&gt;&lt;code&gt;POST /runs/{run_id}/replay&lt;/code&gt;&lt;/a&gt;, and the rules for replaying an agent run (a scheduled agent or a chat turn) fit in a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The original run...&lt;/th&gt;
&lt;th&gt;What happens when you ask to replay it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;succeeded&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Refused.&lt;/strong&gt; Its side effects already happened.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is still running&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Refused.&lt;/strong&gt; Wait for it to settle.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed, and we know no tool was called&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Replayed.&lt;/strong&gt; Nothing to repeat.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed after calling tools&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Refused&lt;/strong&gt;, with the list of tools it called.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed, and we can't tell&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Refused.&lt;/strong&gt; Unknown is not none.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the refusals aren't silent. They're an HTTP &lt;code&gt;409&lt;/code&gt; with a message a human can act on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_refuse_if_side_effects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools_executed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tools_executed&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;It is unknown whether this run called any tool before failing, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;so a replay could repeat a side effect. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pass force=true to run it again anyway.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools_executed&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This run already called &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; before failing: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a replay would call them again. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pass force=true to run it again anyway.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;force=true&lt;/code&gt; exists, on purpose. Sometimes repeating the action is fine, or even what you want. But that's a decision for a human who has read the message, not for an automatic retry loop at 3 a.m.&lt;/p&gt;

&lt;p&gt;Three more rules that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A replay is a new run that cites the original&lt;/strong&gt; (&lt;code&gt;replay_of&lt;/code&gt;). We never overwrite history. If the replay fails too, you can tell it apart from the original failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A replay starts in a fresh session.&lt;/strong&gt; The original session may contain a half-finished exchange, and for chat it's the user's own conversation, where a replay has no business writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We never replay "almost".&lt;/strong&gt; If the input file has disappeared from disk, the replay is refused (&lt;code&gt;410 Gone&lt;/code&gt;). Relaunching without it would produce a run that silently differs from the one you think you're reproducing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 9: runs that die without saying goodbye
&lt;/h2&gt;

&lt;p&gt;One last trap. A run marks itself as finished in a &lt;code&gt;finally&lt;/code&gt; block. But when Kubernetes kills a pod, the &lt;code&gt;finally&lt;/code&gt; never runs. The row stays &lt;code&gt;running&lt;/code&gt;. &lt;strong&gt;Forever.&lt;/strong&gt; And a run that's "still running" can never be replayed.&lt;/p&gt;

&lt;p&gt;So whenever we read a run, &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/run_main.py" rel="noopener noreferrer"&gt;we reconcile it&lt;/a&gt;: if it's been &lt;code&gt;running&lt;/code&gt; for more than six hours with no completion recorded, we mark it &lt;code&gt;error&lt;/code&gt; with an explicit message, &lt;em&gt;"no completion was recorded within 6h; its actual outcome is unknown"&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;run_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;run_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;running&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only if still running
&lt;/span&gt;    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finished_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WHERE status = 'running'&lt;/code&gt; makes it safe even when several replicas reconcile the same run at the same time: only the first write lands. Six hours is deliberately generous, so we never close a genuinely long run by mistake. And once reconciled, the run goes through &lt;strong&gt;exactly the same safety rules&lt;/strong&gt; as any other failure. If it had already sent that email, it won't send it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;em&gt;does&lt;/em&gt; get retried automatically
&lt;/h2&gt;

&lt;p&gt;None of this means nothing is automatic. Things are retried silently when, and only when, it's provably harmless:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty model turns.&lt;/strong&gt; Sometimes the model returns an empty turn (it happens, especially with "thinking" models). By definition an empty turn contains no tool call, so asking again can't repeat a side effect. We retry it once before falling back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event-triggered runs.&lt;/strong&gt; Incoming webhooks go through &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/scheduler/backlog_worker.py" rel="noopener noreferrer"&gt;a durable queue&lt;/a&gt;: each event is processed once, a crashed event is picked up again by another worker, and retries back off and stop after a ceiling, so nothing spins forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest footnote on the empty-turn retry: ADK appends the user's message to the session on every request, so a retry leaves that message &lt;strong&gt;twice&lt;/strong&gt; in the history. It's rare and harmless today, but it's on our watch list for very long sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;If you remember one thing from this post, make it these three questions. Ask them before your agent relaunches anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do I remember?&lt;/strong&gt; Is the agent's memory (sessions, events, inputs) stored somewhere that survives a restart?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do I know what it already did?&lt;/strong&gt; Do I record every tool &lt;em&gt;call&lt;/em&gt;, not just every tool &lt;em&gt;answer&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is doing it again harmless?&lt;/strong&gt; And if I can't tell, do I refuse, or do I gamble?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Amnesia is a "no" to the first question. Déjà vu is skipping the other two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve (yet)
&lt;/h2&gt;

&lt;p&gt;What we have today is &lt;strong&gt;safe replay&lt;/strong&gt;: a failed run is either relaunched because we know it's harmless, or refused with an explanation. What we don't have yet is &lt;strong&gt;true resume&lt;/strong&gt;: picking up a run mid-flight, reusing the results of tools that already ran instead of calling them again.&lt;/p&gt;

&lt;p&gt;Getting there probably means making tools themselves idempotent (an idempotency key on every side-effecting call, so "send this email" twice is still one email) and caching tool results per run. That's the next frontier, and it's a hard one. If you've built it cleanly, I'd genuinely love to read about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this overkill?
&lt;/h2&gt;

&lt;p&gt;It's mostly one table (&lt;code&gt;agent_runs&lt;/code&gt;), one setting (&lt;code&gt;session_service_uri&lt;/code&gt;), one callback, and a handful of rules. No new infrastructure.&lt;/p&gt;

&lt;p&gt;And it's what lets our agents run in production for months, across industrial use cases, on Kubernetes with our &lt;a href="https://artifacthub.io/packages/helm/apowerb/apowerb-chart" rel="noopener noreferrer"&gt;Helm chart&lt;/a&gt; and fully on-prem with &lt;a href="https://github.com/apowerb/apowerb-hosting/blob/main/docker-compose/docker-compose.yml" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt;, restarting several times a day without anyone noticing. Which is exactly how it should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The whole stack, database included, comes up with three commands from the &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;&lt;code&gt;apowerb-hosting&lt;/code&gt;&lt;/a&gt; repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/apowerb/apowerb-hosting.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;apowerb-hosting
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./scripts/generate-secrets.sh
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose/docker-compose.yml &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interface is on &lt;code&gt;http://localhost:3000&lt;/code&gt; and the API on port &lt;code&gt;8000&lt;/code&gt;. Add a model key in the interface and you can start breaking agents on purpose: kill the container mid-run, restart it, and watch the session come back.&lt;/p&gt;

&lt;p&gt;Going to Kubernetes? The same repository holds the &lt;a href="https://github.com/apowerb/apowerb-hosting/tree/main/helm/apowerb-chart" rel="noopener noreferrer"&gt;Helm chart&lt;/a&gt; (also on &lt;a href="https://artifacthub.io/packages/helm/apowerb/apowerb-chart" rel="noopener noreferrer"&gt;Artifact Hub&lt;/a&gt;) and plain &lt;a href="https://github.com/apowerb/apowerb-hosting/tree/main/k8s" rel="noopener noreferrer"&gt;Kubernetes manifests&lt;/a&gt;. The &lt;a href="https://docs.apowerb.com/deployment/dockercompose" rel="noopener noreferrer"&gt;Docker Compose guide&lt;/a&gt; and the &lt;a href="https://docs.apowerb.com/deployment/kubernetes" rel="noopener noreferrer"&gt;Kubernetes guide&lt;/a&gt; walk through both, and I wrote up &lt;a href="https://dev.to/anis_meziani_52aab42304a8/deploying-an-open-source-ai-agent-platform-to-kubernetes-the-honest-one-command-version-574"&gt;the honest version of the Helm install&lt;/a&gt;, traps included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you let your agent relaunch itself, check this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Are sessions stored somewhere other than RAM?&lt;/li&gt;
&lt;li&gt;[ ] Is long-term memory scoped per agent &lt;strong&gt;and&lt;/strong&gt; per user?&lt;/li&gt;
&lt;li&gt;[ ] Is every run written down, &lt;strong&gt;with its input&lt;/strong&gt;, before it starts?&lt;/li&gt;
&lt;li&gt;[ ] Are input files copied, not just referenced?&lt;/li&gt;
&lt;li&gt;[ ] Do you record tool &lt;strong&gt;calls&lt;/strong&gt;, including those that never answered?&lt;/li&gt;
&lt;li&gt;[ ] Do you treat "unknown" as "unsafe"?&lt;/li&gt;
&lt;li&gt;[ ] Is a replay a new run that cites the original, in a fresh session?&lt;/li&gt;
&lt;li&gt;[ ] Do runs killed mid-flight get reconciled instead of staying "running" forever?&lt;/li&gt;
&lt;li&gt;[ ] Are old events purged, and old payloads stripped from the context?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before we shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Defaults are written for demos.&lt;/strong&gt; In-memory sessions are perfect until your first restart. Check every default that stores state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never trust "nothing happened" unless you checked.&lt;/strong&gt; Unknown is not none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A refusal with a clear message beats a clever automatic retry.&lt;/strong&gt; Your users will forgive a button that says "this already sent an email, are you sure?". They won't forgive the second email.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Come break it with us
&lt;/h2&gt;

&lt;p&gt;All of this lives in &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;&lt;strong&gt;ApowerB&lt;/strong&gt;&lt;/a&gt;, our open-source agent runtime (Apache 2.0). The replay logic is in &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/run_main.py" rel="noopener noreferrer"&gt;&lt;code&gt;run_main.py&lt;/code&gt;&lt;/a&gt;, the run table in &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/agent_store/run_store.py" rel="noopener noreferrer"&gt;&lt;code&gt;run_store.py&lt;/code&gt;&lt;/a&gt;, and the memory diet in &lt;a href="https://github.com/apowerb/apowerb/blob/main/src/apowerb/core/history_compaction.py" rel="noopener noreferrer"&gt;&lt;code&gt;history_compaction.py&lt;/code&gt;&lt;/a&gt;. The comments explain the scars; go read them, and tell us where we got it wrong.&lt;/p&gt;

&lt;p&gt;Issues and PRs are welcome. If you want a first contribution in this area, &lt;a href="https://github.com/apowerb/apowerb/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; or pick an existing one, and I'm happy to pair on your first PR. And if this post was useful, a star on the repo helps us a lot.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosting (Docker Compose, Helm chart, Kubernetes manifests):&lt;/strong&gt; &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb-hosting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm chart on Artifact Hub:&lt;/strong&gt; &lt;a href="https://artifacthub.io/packages/helm/apowerb/apowerb-chart" rel="noopener noreferrer"&gt;artifacthub.io/packages/helm/apowerb/apowerb-chart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker image:&lt;/strong&gt; &lt;a href="https://hub.docker.com/r/apowerb/apowerb" rel="noopener noreferrer"&gt;hub.docker.com/r/apowerb/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://docs.apowerb.com/" rel="noopener noreferrer"&gt;docs.apowerb.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product page:&lt;/strong&gt; &lt;a href="https://thaink2.com/apowerb" rel="noopener noreferrer"&gt;thaink2.com/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;See an agent run end to end (75s):&lt;/strong&gt; &lt;a href="https://youtu.be/L3KoK0gqekY" rel="noopener noreferrer"&gt;youtu.be/L3KoK0gqekY&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Now I'm curious: what does your agent do when it crashes halfway through a task?&lt;/strong&gt; &lt;br&gt;
Start over, resume, give up? Tell me in the comments, I read every one.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Deploying an open-source AI agent platform to Kubernetes: the honest one-command version</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:13:00 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/deploying-an-open-source-ai-agent-platform-to-kubernetes-the-honest-one-command-version-574</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/deploying-an-open-source-ai-agent-platform-to-kubernetes-the-honest-one-command-version-574</guid>
      <description>&lt;p&gt;&lt;em&gt;Every "deploy X to Kubernetes in one command" article is technically true and practically a lie. The &lt;code&gt;helm install&lt;/code&gt; really is one line. What the post skips is the empty cluster with no storage class, no ingress controller, no cert-manager, and no DNS. That 90% is what stands between you and a working URL. So here's the honest version for &lt;a href="https://thaink2.com/opensource" rel="noopener noreferrer"&gt;ApowerB&lt;/a&gt;, the open-source agent platform: the chart genuinely installs in one command, and here is everything around it, traps included.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the chart actually deploys
&lt;/h2&gt;

&lt;p&gt;ApowerB ships an official Helm chart (&lt;code&gt;apowerb-chart&lt;/code&gt;, OCI, cosign-signed) that stands up the whole stack, not just the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt; and &lt;strong&gt;Next.js frontend&lt;/strong&gt;: the agent runtime and its UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt;: where your agents live (they're rows, materialized into executable modules at load time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;th2etl&lt;/strong&gt;: pipeline and orchestration, with a seed job&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;th2pulse&lt;/strong&gt;: log and trace storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;otel-collector&lt;/strong&gt;: telemetry routing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;th2forecast&lt;/strong&gt;: optional forecasting engine (disabled by default)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;PersistentVolumeClaim&lt;/strong&gt; for uploads, RAG artifacts and agent pools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a platform, not a container. Which is exactly why the cluster prerequisites below matter more than for a toy app.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fast path (local or dev cluster)
&lt;/h2&gt;

&lt;p&gt;If you already have a cluster with a working storage class, two commands get you running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Generate the secrets.&lt;/strong&gt; The chart refuses to start without them, on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;umask &lt;/span&gt;077
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; values-secrets.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;YAML&lt;/span&gt;&lt;span class="sh"&gt;
backend:
  env:
    encryptKey: "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
th2etl:
  apiKey: "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
th2pulse:
  ingestToken: "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
  queryToken: "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
postgres:
  password: "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 16&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;YAML
&lt;/span&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 values-secrets.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Install:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; apowerb &lt;span class="se"&gt;\&lt;/span&gt;
  oci://registry-1.docker.io/apowerb/apowerb-chart &lt;span class="nt"&gt;--version&lt;/span&gt; 0.4.23 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; apowerb &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--values&lt;/span&gt; values-secrets.yaml &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then port-forward and open the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apowerb port-forward svc/apowerb-frontend 13000:3000 &amp;amp;
&lt;span class="c"&gt;# http://localhost:13000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a model key in the UI and you have a working agent platform on your cluster. That's the "one command" part, and it's real.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one rule that bites everyone:&lt;/strong&gt; pass &lt;code&gt;--values values-secrets.yaml&lt;/code&gt; on &lt;em&gt;every&lt;/em&gt; &lt;code&gt;helm upgrade&lt;/code&gt;. Drop it and you rotate your own credentials out from under a running database. Keep that file safe and version it in your secret store, not in Git.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The honest production path (empty cluster to HTTPS URL)
&lt;/h2&gt;

&lt;p&gt;A real cluster rarely has everything wired. Here's the full walk, which is where most tutorials go quiet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;Kubernetes 1.28+. Three nodes at 4 vCPU / 8 GB give comfortable headroom. Check the ground first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes          &lt;span class="c"&gt;# all Ready&lt;/span&gt;
kubectl get storageclass   &lt;span class="c"&gt;# must not be empty&lt;/span&gt;
kubectl get ingressclass   &lt;span class="c"&gt;# must exist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1: Storage that actually binds
&lt;/h3&gt;

&lt;p&gt;Lean managed clusters often ship with no default storage class. If &lt;code&gt;get storageclass&lt;/code&gt; is empty, install one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; local-path-storage.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.37/deploy/local-path-storage.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; local-path-storage.yaml
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; local-path-storage rollout status deploy/local-path-provisioner &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s

kubectl patch storageclass local-path &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trap:&lt;/strong&gt; most storage classes are &lt;code&gt;WaitForFirstConsumer&lt;/code&gt;, so a lone PVC stays &lt;code&gt;Pending&lt;/code&gt; and looks broken when it's fine. Test with a PVC &lt;em&gt;and&lt;/em&gt; a pod together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;YAML&lt;/span&gt;&lt;span class="sh"&gt;'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: probe-pvc}
spec:
  accessModes: ["ReadWriteOnce"]
  resources: {requests: {storage: 1Gi}}
---
apiVersion: v1
kind: Pod
metadata: {name: probe-pod}
spec:
  restartPolicy: Never
  containers:
    - name: writer
      image: busybox:1.37
      command: ["sh","-c","echo ok &amp;gt; /data/marker &amp;amp;&amp;amp; cat /data/marker &amp;amp;&amp;amp; sleep 20"]
      volumeMounts: [{name: data, mountPath: /data}]
  volumes:
    - {name: data, persistentVolumeClaim: {claimName: probe-pvc}}
&lt;/span&gt;&lt;span class="no"&gt;YAML
&lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;25 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; kubectl get pvc probe-pvc &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; kubectl logs probe-pod
kubectl delete pvc probe-pvc pod probe-pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want &lt;code&gt;Bound&lt;/code&gt; and &lt;code&gt;ok&lt;/code&gt; in the logs. If this fails, stop. Nothing else will work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Ingress
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get ingressclass
kubectl get svc &lt;span class="nt"&gt;-A&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;LoadBalancer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;strong&gt;class name&lt;/strong&gt; (Traefik and ingress-nginx are the usual managed add-ons) and the &lt;strong&gt;EXTERNAL-IP&lt;/strong&gt; for DNS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: cert-manager and a staging issuer
&lt;/h3&gt;

&lt;p&gt;Let's Encrypt rate-limits at 5 failures/hour, so always start on staging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-staging&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://acme-staging-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@example.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-staging-account-key&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;   &lt;span class="c1"&gt;# your actual class from Step 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trap that costs an afternoon:&lt;/strong&gt; use &lt;code&gt;ingressClassName&lt;/code&gt;, not the deprecated &lt;code&gt;class&lt;/code&gt; annotation. Otherwise the ACME challenge Ingress gets no recognised class and the certificate sits at &lt;code&gt;Ready: False&lt;/code&gt; forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Deploy with ingress and TLS
&lt;/h3&gt;

&lt;p&gt;Same install command, now with public access turned on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; apowerb &lt;span class="se"&gt;\&lt;/span&gt;
  oci://registry-1.docker.io/apowerb/apowerb-chart &lt;span class="nt"&gt;--version&lt;/span&gt; 0.4.23 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; apowerb &lt;span class="nt"&gt;--values&lt;/span&gt; values-secrets.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; ingress.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; ingress.className&lt;span class="o"&gt;=&lt;/span&gt;traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; ingress.host&lt;span class="o"&gt;=&lt;/span&gt;apowerb.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; ingress.tlsEnabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; ingress.annotations.&lt;span class="s2"&gt;"cert-manager&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;io/cluster-issuer"&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;letsencrypt-staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the first admin while you're at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nt"&gt;--set&lt;/span&gt; superadmin.email&lt;span class="o"&gt;=&lt;/span&gt;admin@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; superadmin.password&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;a strong one&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: DNS and verify
&lt;/h3&gt;

&lt;p&gt;Point an A record at the &lt;code&gt;EXTERNAL-IP&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apowerb get certificate
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; apowerb describe certificate apowerb-tls
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stuck at &lt;code&gt;Ready: False&lt;/code&gt;? It's almost always one of: DNS not resolving to the load balancer, port 80 closed (HTTP-01 needs it), the &lt;code&gt;class&lt;/code&gt; vs &lt;code&gt;ingressClassName&lt;/code&gt; trap, or a staging rate limit. Once it's &lt;code&gt;True&lt;/code&gt;, swap the issuer to production (&lt;code&gt;https://acme-v02.api.letsencrypt.org/directory&lt;/code&gt;) and reapply.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuring the platform
&lt;/h2&gt;

&lt;p&gt;The same &lt;code&gt;values&lt;/code&gt; mechanism covers the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default LLM&lt;/strong&gt;: &lt;code&gt;defaultLlm.model&lt;/code&gt; and &lt;code&gt;defaultLlm.apiKey&lt;/code&gt; for agents without their own key (LiteLLM under the hood, so OpenAI / Anthropic / Mistral / Gemini / local all work)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrations&lt;/strong&gt;: &lt;code&gt;GOOGLE_INTEGRATION_*&lt;/code&gt; (Drive, Gmail, Calendar), &lt;code&gt;MICROSOFT_INTEGRATION_*&lt;/code&gt; (Outlook, webhooks, mail), &lt;code&gt;SMTP_*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: &lt;code&gt;storage.mode&lt;/code&gt; and &lt;code&gt;S3_*&lt;/code&gt; to push &lt;code&gt;bi_store&lt;/code&gt;, &lt;code&gt;uploads&lt;/code&gt;, &lt;code&gt;artifacts_store&lt;/code&gt; and &lt;code&gt;agents_pool&lt;/code&gt; to object storage instead of the PVC&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;The install itself is genuinely one command against an OCI chart, and the chart is honest: it won't boot without real secrets, and it bundles the orchestration, log store and telemetry rather than leaving them as "exercises for the reader." The work that remains isn't ApowerB's, it's the cluster's: storage, ingress, cert-manager, DNS. And that's true of anything serious you deploy.&lt;/p&gt;

&lt;p&gt;If you want to see the easy version first, the Docker Compose stack comes up locally in a few commands. But if you're putting agents in front of real users, the Helm chart is the path, and now you've seen all of it, traps included.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://thaink2.com/opensource" rel="noopener noreferrer"&gt;https://thaink2.com/opensource&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/apowerb" rel="noopener noreferrer"&gt;https://github.com/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm chart (Artifact Hub):&lt;/strong&gt; &lt;a href="https://artifacthub.io/packages/helm/apowerb/apowerb-chart" rel="noopener noreferrer"&gt;https://artifacthub.io/packages/helm/apowerb/apowerb-chart&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment docs:&lt;/strong&gt; &lt;a href="https://docs.apowerb.com/deployment/kubernetes" rel="noopener noreferrer"&gt;https://docs.apowerb.com/deployment/kubernetes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Hub:&lt;/strong&gt; &lt;a href="https://hub.docker.com/r/apowerb/apowerb" rel="noopener noreferrer"&gt;https://hub.docker.com/r/apowerb/apowerb&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Deployed it on something interesting? Drop your cluster setup in the comments. &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vhypahx775ouqg4wupf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vhypahx775ouqg4wupf.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>helm</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your agent framework stops at the demo. This open-source stack ships to production.</title>
      <dc:creator>Anis Meziani</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:47:05 +0000</pubDate>
      <link>https://dev.to/anis_meziani_52aab42304a8/your-agent-framework-stops-at-the-demo-this-open-source-stack-ships-to-production-3ogd</link>
      <guid>https://dev.to/anis_meziani_52aab42304a8/your-agent-framework-stops-at-the-demo-this-open-source-stack-ships-to-production-3ogd</guid>
      <description>&lt;p&gt;Building an AI agent is easy. Building the system around it is where things get complicated.&lt;/p&gt;

&lt;p&gt;You can create an agent in a few lines of Python. Then you need an API, persistence, authentication, tool integrations, RAG, scheduling, observability, a UI, deployment, and a way to operate the whole thing. That's the gap I wanted to explore when I started digging into &lt;strong&gt;apowerb&lt;/strong&gt;, an open-source AI agent framework from &lt;a href="https://thaink2.com/opensource" rel="noopener noreferrer"&gt;thaink²&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Rather than treating it as another agent SDK, I wanted to answer a more specific question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What does an AI agent runtime actually need to provide once the prototype becomes a real system?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article walks through apowerb's architecture, its runtime model, RAG engine, Text-to-SQL support, event-driven execution, deployment model, and how it compares with other popular approaches.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/apowerb" rel="noopener noreferrer"&gt;
        apowerb
      &lt;/a&gt; / &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;
        apowerb
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      The open-source agentic framework to build, orchestrate, and operate production AI agents.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;p&gt;&lt;a href="https://www.linkedin.com/company/thaink2" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7a88102f248fb35dcd895f4d1ecd57cd0389543686bd54b72ed6ea269bdca472/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c696e6b6564496e2d746861696e6b2543322542322d3041363643323f6c6f676f3d6c696e6b6564696e266c6f676f436f6c6f723d7768697465" alt="LinkedIn"&gt;&lt;/a&gt;
&lt;a href="https://www.youtube.com/@thaink2" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/40459ab68e77273e1156319e1043f86a4226fbaa0060bd4da39c2214d98a479f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f596f75547562652d40746861696e6b322d4646303030303f6c6f676f3d796f7574756265266c6f676f436f6c6f723d7768697465" alt="YouTube"&gt;&lt;/a&gt;
&lt;a href="https://github.com/apowerb/apowerb/stargazers" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/9635cac6f0bcd22fb443d8f5affc535d83aa527d0b7f8f101ab305e6930ecb80/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f61706f776572622f61706f776572623f7374796c653d736f6369616c" alt="GitHub stars"&gt;&lt;/a&gt;
&lt;a href="https://pypi.org/project/apowerb/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/f928a31710f43fce1671e279218a30ed7c429c05b749f6dc9dfe079005e9878d/68747470733a2f2f696d672e736869656c64732e696f2f707970692f762f61706f776572623f6c6f676f3d70797069266c6f676f436f6c6f723d7768697465" alt="PyPI version"&gt;&lt;/a&gt;
&lt;a href="https://www.python.org/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/2b6aa5b32389b93f49afea76fcbc0f2ff2a0090a347661fadba906d0d77c4010/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e2d332e31302532422d3337373641423f6c6f676f3d707974686f6e266c6f676f436f6c6f723d7768697465" alt="Python"&gt;&lt;/a&gt;
&lt;a href="https://github.com/apowerb/apowerb/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b29de0acdfd19013f1f02689b15c933e4a6c145be9efa718288f88ba3280b1c5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d417061636865253230322e302d626c75652e737667" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;br&gt;
&lt;p&gt;&lt;a href="https://discord.com/channels/1470717940075597896" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/8f64c117b97be1456fe243da19bd34f6aa782a09ad06ba162a33ef665cac9d27/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6f696e2532306f7572253230446973636f72642d3538363546323f7374796c653d666f722d7468652d6261646765266c6f676f3d646973636f7264266c6f676f436f6c6f723d7768697465" alt="Join our Discord"&gt;&lt;/a&gt;
&lt;a href="https://docs.apowerb.com" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/893f4c9187ad78a91f45ed3f031ed77585560fb1953b2cdd6ba638e2de664686/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f52656164253230746865253230446f63732d3645353643463f7374796c653d666f722d7468652d6261646765266c6f676f3d72656164746865646f6373266c6f676f436f6c6f723d7768697465" alt="Read the Docs"&gt;&lt;/a&gt;&lt;/p&gt;

  
  
  &lt;img alt="apowerb, the open-source runtime for humans and AI agents" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fapowerb%2Fapowerb%2Fmain%2F.github%2Fassets%2Freadme%2Fhero-light.png" width="100%"&gt;

&lt;br&gt;
&lt;p&gt;&lt;strong&gt;Build, run and govern AI agents that work while you don't.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The open-source agentic framework to build, orchestrate, and operate production AI agents.&lt;/p&gt;
&lt;p&gt;
  &lt;a href="https://docs.apowerb.com/" rel="nofollow noopener noreferrer"&gt;Documentation&lt;/a&gt; •
  &lt;a href="https://docs.apowerb.com/quickstart" rel="nofollow noopener noreferrer"&gt;Quickstart&lt;/a&gt; •
  &lt;a href="https://docs.apowerb.com/api-reference/introduction" rel="nofollow noopener noreferrer"&gt;API Reference&lt;/a&gt; •
  &lt;a href="https://docs.apowerb.com/deployment/dockercompose" rel="nofollow noopener noreferrer"&gt;Deployment&lt;/a&gt; •
  &lt;a href="https://thaink2.com" rel="nofollow noopener noreferrer"&gt;thaink2&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fapowerb%2Fapowerb%2Fmain%2F.github%2Fassets%2Freadme%2Ffeatures-light.png" class="article-body-image-wrapper"&gt;&lt;img alt="RAG, Text-to-SQL, Orchestration, Email triggers, Multi-LLM, REST API + UI" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fapowerb%2Fapowerb%2Fmain%2F.github%2Fassets%2Freadme%2Ffeatures-light.png" width="100%"&gt;&lt;/a&gt;&lt;br&gt;
&lt;br&gt;
&lt;br&gt;&lt;br&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fapowerb%2Fapowerb%2Fmain%2F.github%2Fassets%2Freadme%2Fworkflow-light.png" class="article-body-image-wrapper"&gt;&lt;img alt="Step 1, build: define the agent in the UI or the database. Step 2, equip: give it knowledge and tools. Step 3, run: watch every step." src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fapowerb%2Fapowerb%2Fmain%2F.github%2Fassets%2Freadme%2Fworkflow-light.png" width="100%"&gt;&lt;/a&gt;&lt;br&gt;
&lt;br&gt;
&lt;/p&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;apowerb is an open-source platform to build, run and govern AI agents in
production. It covers multi-agent orchestration, RAG and Text-to-SQL, MCP servers
and built-in business tools, email- and schedule-triggered runs, token quotas and
session audit. You can self-host it with Docker Compose or Helm. The core is
Apache-2.0; a few enterprise modules (billing, identity-provider sign-in, MFA
agent evaluation, the supervision screen) are sold separately by thaink².&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build agents&lt;/strong&gt; from the web interface or the REST API — instructions, model
tools, knowledge bases — and compose them: sequential, parallel, loop, sub-agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect them to your systems&lt;/strong&gt;: any MCP server, plus built-in tools for Google
Workspace, Microsoft 365, GitHub, HubSpot, Pipedrive, Odoo, SQL databases
Oracle, MongoDB, S3,…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  What is apowerb, exactly?
&lt;/h2&gt;

&lt;p&gt;Most agent frameworks start from a library. You import the framework, define an agent, give it tools, connect an LLM, and build your application. That model is useful, but it leaves a lot of infrastructure outside the framework: API layer, persistence, authentication, UI, deployment, scheduling, event handling, observability, RAG, integrations.&lt;/p&gt;

&lt;p&gt;apowerb takes a different approach. Instead of treating the agent as an object embedded inside an application, it treats the agent as something that can be &lt;strong&gt;defined, stored, materialized and operated by a runtime&lt;/strong&gt;. The stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; : REST APIs and Server-Sent Events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typer&lt;/strong&gt; : the CLI (&lt;code&gt;apowerb serve&lt;/code&gt;, &lt;code&gt;apowerb agents list&lt;/code&gt;, …)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; : the web UI (build agents, give them tools/knowledge, run and inspect them)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; : agent and configuration state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google ADK&lt;/strong&gt; : the execution engine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteLLM&lt;/strong&gt; : the model routing layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose / Helm&lt;/strong&gt; : deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is closer to an application runtime than to a standalone Python library.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture in one picture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────────┐
│                     Clients / UI                        │
│    Next.js UI  •  Typer CLI  •  REST API  •  SSE         │
└──────────────────────────┬─────────────────────────────┘
                           │
                           ▼
┌────────────────────────────────────────────────────────┐
│                 FastAPI Orchestrator                    │
│   Agent materialization   •   Webhooks (Pub/Sub)        │
│   Cron / event scheduling •   SSE output stream         │
└──────────────────────────┬─────────────────────────────┘
                           │
                           ▼
┌────────────────────────────────────────────────────────┐
│              Execution Engine Google ADK              │
│   Base • Sequential • Parallel • Loop                   │
│   Hierarchical sub-agents  •  Human-in-the-loop         │
└─────────────┬───────────────────────────┬──────────────┘
              │                           │
              ▼                           ▼
┌─────────────────────────┐    ┌─────────────────────────┐
│   LiteLLM Model Router  │    │     Tools &amp;amp; Services     │
│ OpenAI • Anthropic      │    │ M365 • Google • SQL      │
│ Gemini • Mistral • local│    │ RAG • Webhooks           │
└─────────────────────────┘    └─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four orchestration patterns are built into the runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;base&lt;/strong&gt; : single-turn conversational agents with tool execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sequential&lt;/strong&gt; : deterministic step-by-step pipelines passing context downstream&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;parallel&lt;/strong&gt; : concurrent branches aggregated for synthesis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;loop&lt;/strong&gt; : evaluator/optimizer cycles that iterate until a target condition or stop token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hierarchical sub-agents let a coordinator delegate work to specialized agents. The interesting part, though, isn't the list of patterns, it's what happens &lt;em&gt;before&lt;/em&gt; execution.&lt;/p&gt;




&lt;h2&gt;
  
  
  The interesting choice: agents as runtime objects
&lt;/h2&gt;

&lt;p&gt;In a typical application, an agent is defined directly in Python and the application owns that definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SomeAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[...],&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;apowerb takes another route. Agent definitions, tools, skills and their configuration live in PostgreSQL. At startup, the backend uses those definitions to &lt;strong&gt;materialize executable Python modules&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent definition ─► PostgreSQL ─► Materialization ─► Python module ─► Google ADK
                                                                        ├─► Tools
                                                                        ├─► RAG
                                                                        └─► LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changes the operational model. The application isn't rebuilt every time the agent catalogue changes; the runtime loads definitions from the database and turns them into executable agents. For an organisation managing many agents, that's a meaningful distinction, the agent becomes closer to a &lt;strong&gt;runtime-managed resource&lt;/strong&gt; than a static piece of application code. It's the detail I found most interesting in the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Model access through LiteLLM
&lt;/h2&gt;

&lt;p&gt;The execution layer is deliberately separated from model access. apowerb uses &lt;strong&gt;LiteLLM&lt;/strong&gt; as the routing layer, so the runtime can work with multiple providers without coupling an agent's tools to one vendor. The documented stack includes Anthropic, OpenAI, Mistral, Gemini, OVHcloud, Groq, and local Ollama/vLLM deployments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent ─► Google ADK ─► LiteLLM ─┬─► OpenAI
                                ├─► Anthropic
                                └─► Local (Ollama / vLLM)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tools and orchestration logic don't need to know which provider ultimately executes the model call, useful for teams that want to swap or benchmark models.&lt;/p&gt;




&lt;h2&gt;
  
  
  RAG is part of the stack
&lt;/h2&gt;

&lt;p&gt;RAG isn't treated as an external SaaS dependency. apowerb includes &lt;strong&gt;th2rag&lt;/strong&gt;, a standalone FastAPI service for ingestion and retrieval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document ─► Conversion (Docling) ─► Chunking ─► Embeddings ─► LanceDB ─► Retrieval ─► Generation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current implementation uses &lt;strong&gt;Docling&lt;/strong&gt; for document conversion, its &lt;strong&gt;HybridChunker&lt;/strong&gt; for semantic chunking, &lt;strong&gt;sentence-transformers/gtr-t5-large&lt;/strong&gt; for embeddings, &lt;strong&gt;LanceDB&lt;/strong&gt; as the vector store, a LanceDB retriever for similarity search, and &lt;strong&gt;Mistral AI&lt;/strong&gt; for generation. Each stage sits behind a base class (&lt;code&gt;BaseEmbedding&lt;/code&gt;, &lt;code&gt;BaseGenerator&lt;/code&gt;, …), so swapping the embedding model or the LLM is a matter of subclassing, not surgery.&lt;/p&gt;

&lt;p&gt;The service can index local files, URLs, database query results and S3 objects, into knowledge bases associated with individual agents rather than one global index. That makes RAG a runtime capability instead of something each application assembles separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Native Text-to-SQL
&lt;/h2&gt;

&lt;p&gt;An agent can connect to PostgreSQL or MySQL, introspect the schema, generate SQL from a natural-language question, execute it and return the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Which customers generated the most revenue last quarter?"
        │
        ▼
   Agent / LLM ─► Schema context ─► SQL generation ─► Query ─► Results ─► NL response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part isn't that an LLM can write SQL, it's that the connection, schema introspection, generation and execution all happen &lt;em&gt;inside the agent runtime&lt;/em&gt;. Text-to-SQL becomes another runtime capability rather than an integration every application builds from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Event-driven agents
&lt;/h2&gt;

&lt;p&gt;Most agent demos are request/response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP request ─► Agent ─► Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;apowerb can also work in the opposite direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;External event ─► Webhook ─► Agent ─► Tools / RAG / LLM ─► Action / Result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current implementation includes email-triggered agents. Gmail uses Google Cloud Pub/Sub push notifications; Outlook uses Microsoft Graph change subscriptions with &lt;code&gt;clientState&lt;/code&gt; validation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email arrives ─► Provider notification ─► apowerb webhook ─► Fetch message ─► Run agent ─► Log result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subscription renewal is handled in the background: Gmail watches expire after seven days, Outlook after three, and the runtime periodically renews anything approaching expiration. Combined with cron-style scheduled runs and SSE streaming (token-by-token output, RAG ingestion progress, notifications), this moves the runtime beyond chatbots, the agent becomes a component in an event-driven system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The open-source boundary
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;core stack is Apache 2.0&lt;/strong&gt; ; the framework plus supporting services like the RAG engine, ETL components, observability and web UI. The project follows an open-core model: some commercial extensions (billing, MFA, identity-provider SSO, agent evaluation, supervision, organisation management) are maintained separately.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;TH2_EXTENSIONS&lt;/code&gt; environment variable declares which commercial bricks to load; if a brick isn't installed or enabled, the core doesn't depend on it. Routes belonging to an absent brick return a clean &lt;code&gt;404&lt;/code&gt;. The useful distinction: &lt;strong&gt;the open-source runtime remains fully usable without the commercial extensions&lt;/strong&gt;, the core isn't an artificially limited demo.&lt;/p&gt;




&lt;h2&gt;
  
  
  It's more than one repository
&lt;/h2&gt;

&lt;p&gt;A common pattern in AI tooling is "open-source framework + several proprietary services around it." Here, much more of the surrounding infrastructure is exposed. The thaink² GitHub org holds 10 repositories, all Apache 2.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apowerb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FastAPI backend, CLI and ADK runtime&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apowerb-ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Visual agent builder and monitoring&lt;/td&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;th2rag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RAG, parsing, embeddings, vector search&lt;/td&gt;
&lt;td&gt;LanceDB / Docling / Mistral&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;th2etl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ETL pipelines&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;th2pulse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Observability and logs&lt;/td&gt;
&lt;td&gt;OpenTelemetry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;th2forecast&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time-series forecasting&lt;/td&gt;
&lt;td&gt;R&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apowerb-hosting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Docker Compose / Kubernetes / Helm&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apowerb-docs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Documentation&lt;/td&gt;
&lt;td&gt;Mintlify / MDX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent-hub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Community agent templates&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can read and run the RAG engine, the scheduler and the telemetry pipeline, there's no hidden proprietary service doing the heavy lifting behind an API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Run the full stack
&lt;/h2&gt;

&lt;p&gt;The fastest way to understand the project is to run it. The hosting repository provides a Docker Compose setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/apowerb/apowerb-hosting.git
&lt;span class="nb"&gt;cd &lt;/span&gt;apowerb-hosting

&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
./scripts/generate-secrets.sh

docker compose &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose/docker-compose.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="se"&gt;\&lt;/span&gt;
  up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI comes up on &lt;code&gt;http://localhost:3000&lt;/code&gt;, the API on &lt;code&gt;:8000&lt;/code&gt;, and PostgreSQL runs inside the stack. You still provide an LLM API key, through the UI, or via &lt;code&gt;DEFAULT_LLM_MODEL&lt;/code&gt; / &lt;code&gt;DEFAULT_LLM_API_KEY&lt;/code&gt;. If you only want the Python package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv add apowerb   &lt;span class="c"&gt;# or: pip install apowerb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two entry points, two purposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apowerb package   ─► embed the runtime in your application
apowerb-hosting   ─► run the complete platform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How it compares
&lt;/h2&gt;

&lt;p&gt;Comparisons between agent frameworks are easy to get wrong because they solve different problems. The useful question isn't &lt;em&gt;"which framework is best?"&lt;/em&gt; but &lt;em&gt;"which layer does each project expect you to own?"&lt;/em&gt; Below, "built-in" means the capability is part of the platform rather than something you assemble around it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;apowerb&lt;/th&gt;
&lt;th&gt;LangGraph&lt;/th&gt;
&lt;th&gt;CrewAI&lt;/th&gt;
&lt;th&gt;n8n + LLM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Multi-model&lt;/td&gt;
&lt;td&gt;LiteLLM&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in RAG&lt;/td&gt;
&lt;td&gt;th2rag&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email webhooks&lt;/td&gt;
&lt;td&gt;Gmail + Outlook&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Text-to-SQL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via tools&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Included UI&lt;/td&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Helm deployment&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main approach&lt;/td&gt;
&lt;td&gt;Runtime / platform&lt;/td&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Workflow automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction isn't one checkbox, it's the architecture. &lt;strong&gt;LangGraph&lt;/strong&gt; and &lt;strong&gt;CrewAI&lt;/strong&gt; are frameworks you embed into an application. &lt;strong&gt;n8n&lt;/strong&gt; comes from workflow automation, where an LLM is one node in a larger graph. &lt;strong&gt;apowerb&lt;/strong&gt; provides the runtime &lt;em&gt;around&lt;/em&gt; the agent: API, persistence, UI, integrations, scheduling, RAG and deployment. Different design choices, not just different implementations of the same product.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I found interesting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Agents are treated as managed runtime resources.&lt;/strong&gt; The database-backed definition and materialization model is the most distinctive architectural choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. RAG isn't an external dependency.&lt;/strong&gt; The project ships its own RAG service instead of requiring every deployment to assemble one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Event-driven execution is first-class.&lt;/strong&gt; Gmail, Outlook, schedules and SSE make "proactive" agents implementable, not just request/response chatbots.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;Not everything is settled. The release cadence is high, so the API isn't frozen, expect to track changes if you build on it now. Schema is created by &lt;code&gt;ensure_*&lt;/code&gt; functions at startup rather than Alembic, which is fine today but means no reversible migrations. And the external community is still small; most contribution looks internal so far, so you'd be an early adopter.&lt;/p&gt;

&lt;p&gt;None of that is a dealbreaker, it's the normal shape of a young project that's visibly running in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Worth a clone if…
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;you want RAG, tool auth, an API and a UI to already exist so you can build the actual agent;&lt;/li&gt;
&lt;li&gt;you're a data team and Text-to-SQL over your warehouse plus scheduled runs maps to your use case;&lt;/li&gt;
&lt;li&gt;you need everything self-hosted and auditable, your models, your data, Apache 2.0;&lt;/li&gt;
&lt;li&gt;you're benchmarking agent frameworks and want to see where the "runtime, not library" line falls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three experiments that take minutes each: point an agent at a Postgres and query it in English; drop a folder of PDFs into a knowledge base and ask questions; set a Gmail trigger that summarizes and routes incoming mail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Core: &lt;a href="https://github.com/apowerb/apowerb" rel="noopener noreferrer"&gt;github.com/apowerb/apowerb&lt;/a&gt;  Apache-2.0&lt;/li&gt;
&lt;li&gt;Web site: &lt;a href="https://thaink2.com/opensource" rel="noopener noreferrer"&gt;thaink2.com/opensource&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Deployment: &lt;a href="https://github.com/apowerb/apowerb-hosting" rel="noopener noreferrer"&gt;apowerb/apowerb-hosting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://docs.apowerb.com/" rel="noopener noreferrer"&gt;docs.apowerb.com&lt;/a&gt;  &lt;a href="https://docs.apowerb.com/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;, &lt;a href="https://docs.apowerb.com/api-reference/introduction" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it, I'd be curious what you build  drop a note in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>opensource</category>
      <category>python</category>
    </item>
  </channel>
</rss>
