<?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: Lars Winstand</title>
    <description>The latest articles on DEV Community by Lars Winstand (@lars_winstand).</description>
    <link>https://dev.to/lars_winstand</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%2F3908932%2Feb8bc1ff-405f-4ef0-8204-ba1ed7caa59f.jpeg</url>
      <title>DEV Community: Lars Winstand</title>
      <link>https://dev.to/lars_winstand</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lars_winstand"/>
    <language>en</language>
    <item>
      <title>My AI agent failed obvious tasks, and 49% fewer retrieval misses changed how I debugged it</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Mon, 21 Sep 2026 22:09:46 +0000</pubDate>
      <link>https://dev.to/lars_winstand/my-ai-agent-failed-obvious-tasks-and-49-fewer-retrieval-misses-changed-how-i-debugged-it-5ej</link>
      <guid>https://dev.to/lars_winstand/my-ai-agent-failed-obvious-tasks-and-49-fewer-retrieval-misses-changed-how-i-debugged-it-5ej</guid>
      <description>&lt;p&gt;I used to blame the model.&lt;/p&gt;

&lt;p&gt;If an agent missed a refund rule, forgot a tool result from 10 seconds ago, or grabbed the wrong SKU from docs, I’d assume GPT-5 or Claude had a reasoning problem.&lt;/p&gt;

&lt;p&gt;I don’t think that anymore.&lt;/p&gt;

&lt;p&gt;A lot of "agent is dumb" bugs are retrieval bugs.&lt;/p&gt;

&lt;p&gt;That sounds obvious in hindsight, but it changes how you debug everything: n8n flows, OpenAI File Search, support bots, internal copilots, and custom agent stacks held together with Redis, Postgres, and optimism.&lt;/p&gt;

&lt;p&gt;Anthropic’s Contextual Retrieval writeup put hard numbers on something a lot of us have seen in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;49% fewer failed retrievals with Contextual Retrieval&lt;/li&gt;
&lt;li&gt;67% fewer failed retrievals when reranking is added&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a small lift.&lt;/p&gt;

&lt;p&gt;That is a giant sign that many agent failures happen before the model even starts reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure looked like reasoning. It wasn’t.
&lt;/h2&gt;

&lt;p&gt;Here’s the pattern I kept seeing.&lt;/p&gt;

&lt;p&gt;The agent could:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;summarize a long PDF&lt;/li&gt;
&lt;li&gt;call an API correctly&lt;/li&gt;
&lt;li&gt;produce a decent customer reply&lt;/li&gt;
&lt;li&gt;follow a multi-step workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it would fail one painfully obvious step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;miss the refund window in the policy doc&lt;/li&gt;
&lt;li&gt;use the wrong product SKU&lt;/li&gt;
&lt;li&gt;forget a prior tool result&lt;/li&gt;
&lt;li&gt;ignore a customer-specific exception&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When that happens, it &lt;em&gt;feels&lt;/em&gt; like bad reasoning.&lt;/p&gt;

&lt;p&gt;But usually one exact fact was missing at one exact moment.&lt;/p&gt;

&lt;p&gt;That’s not a reasoning failure.&lt;/p&gt;

&lt;p&gt;That’s failed fetch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugging mistake: treating all memory as one thing
&lt;/h2&gt;

&lt;p&gt;A lot of teams say "memory" like it’s one subsystem.&lt;/p&gt;

&lt;p&gt;It isn’t.&lt;/p&gt;

&lt;p&gt;In practice, you usually have at least 3 different layers:&lt;/p&gt;

&lt;p&gt;| Memory type | Scope | Best for |&lt;br&gt;
|----------|----------|&lt;br&gt;
| Session/chat memory | Current conversation or run | Short-term continuity |&lt;br&gt;
| Durable memory | Across runs, users, or sessions | Preferences, state, long-lived facts |&lt;br&gt;
| Retrieval | Pulling external facts on demand | Docs, policies, tool outputs, exact references |&lt;/p&gt;

&lt;p&gt;If your n8n agent forgets a tool result from the same run, that’s probably session memory.&lt;/p&gt;

&lt;p&gt;If it loses a customer preference from yesterday, that’s durable memory.&lt;/p&gt;

&lt;p&gt;If it can’t find the refund rule that definitely exists in your docs, that’s retrieval.&lt;/p&gt;

&lt;p&gt;Different bug. Different fix.&lt;/p&gt;

&lt;p&gt;This is why debugging gets weird when people throw Redis, Postgres, vector search, chat history, and tool outputs into one bucket called "memory."&lt;/p&gt;
&lt;h2&gt;
  
  
  Long context does not magically fix retrieval
&lt;/h2&gt;

&lt;p&gt;I still hear this one a lot:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We gave the model the docs, so retrieval can’t be the issue.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not true.&lt;/p&gt;

&lt;p&gt;The Lost in the Middle result is still one of the most annoying realities in production: models often do worse when the relevant info is buried in the middle of a long prompt.&lt;/p&gt;

&lt;p&gt;So you can have both of these problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent never retrieves the right fact&lt;/li&gt;
&lt;li&gt;The agent retrieves it, then buries it where the model is less likely to use it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means "the info was technically present" is not a useful defense.&lt;/p&gt;

&lt;p&gt;If the right fact is hidden in a wall of context, your agent can still fail in a way that looks like reasoning.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pure vector search loses stupid fights
&lt;/h2&gt;

&lt;p&gt;This is where I’ll be blunt.&lt;/p&gt;

&lt;p&gt;If your agent needs exact strings, pure embedding search is not enough.&lt;/p&gt;

&lt;p&gt;I’m talking about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;order IDs&lt;/li&gt;
&lt;li&gt;policy titles&lt;/li&gt;
&lt;li&gt;product SKUs&lt;/li&gt;
&lt;li&gt;workflow names&lt;/li&gt;
&lt;li&gt;error codes&lt;/li&gt;
&lt;li&gt;ticket IDs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Semantic retrieval is great until you need literal precision.&lt;/p&gt;

&lt;p&gt;That’s why hybrid retrieval keeps winning in real systems.&lt;/p&gt;

&lt;p&gt;Keyword search + semantic search + reranking is just more reliable than hoping embeddings will infer everything.&lt;/p&gt;

&lt;p&gt;Even OpenAI’s retrieval stack leans this way. That should tell you something.&lt;/p&gt;
&lt;h2&gt;
  
  
  A practical OpenAI File Search setup
&lt;/h2&gt;

&lt;p&gt;If you’re using OpenAI-compatible tooling, retrieval should be in the agent loop instead of relying on the model to remember everything from prior turns.&lt;/p&gt;

&lt;p&gt;Example:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;vector_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector_stores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Support FAQ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector_stores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_and_poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;vector_store_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vector_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_policies.txt&lt;/span&gt;&lt;span class="sh"&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;rb&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;That gives the agent something to query instead of forcing conversational recall to do all the work.&lt;/p&gt;

&lt;p&gt;You can also upload remote files directly:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BytesIO&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&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="n"&gt;requests&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;https://cdn.openai.com/API/docs/deep_research_blog.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&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;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;file_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BytesIO&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="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;file&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;deep_research_blog.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistants&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;None of this is glamorous.&lt;/p&gt;

&lt;p&gt;That’s exactly why it matters.&lt;/p&gt;

&lt;p&gt;A lot of agent reliability comes from boring retrieval plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sometimes the right move is: stop doing RAG
&lt;/h2&gt;

&lt;p&gt;This is the part more people should talk about.&lt;/p&gt;

&lt;p&gt;Anthropic makes a strong point: if your knowledge base is under roughly 200,000 tokens, you may be better off putting the whole corpus in context instead of building a retrieval pipeline.&lt;/p&gt;

&lt;p&gt;That runs against the default instinct a lot of teams have now, which is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chunk everything&lt;/li&gt;
&lt;li&gt;embed everything&lt;/li&gt;
&lt;li&gt;build RAG first&lt;/li&gt;
&lt;li&gt;debug forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For smaller corpora, that can be overengineering.&lt;/p&gt;

&lt;p&gt;Anthropic also says prompt caching can reduce latency by more than 2x and costs by up to 90%.&lt;/p&gt;

&lt;p&gt;So the tradeoff looks more like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Common failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long-context prompting&lt;/td&gt;
&lt;td&gt;Smaller corpora, roughly under 200k tokens&lt;/td&gt;
&lt;td&gt;Relevant fact is buried or badly ordered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval pipeline&lt;/td&gt;
&lt;td&gt;Larger or changing corpora&lt;/td&gt;
&lt;td&gt;Wrong chunk retrieved or exact term missed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I like this framing because it forces a better question:&lt;/p&gt;

&lt;p&gt;Do you actually need retrieval, or are you building retrieval because that’s what everyone does?&lt;/p&gt;

&lt;h2&gt;
  
  
  How I’d debug this before blaming GPT-5 or Claude
&lt;/h2&gt;

&lt;p&gt;Here’s the checklist I wish more teams used.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Log the exact context the agent saw
&lt;/h3&gt;

&lt;p&gt;Not what was in your database.&lt;/p&gt;

&lt;p&gt;Not what you intended to send.&lt;/p&gt;

&lt;p&gt;The exact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieved chunks&lt;/li&gt;
&lt;li&gt;prior messages&lt;/li&gt;
&lt;li&gt;tool outputs&lt;/li&gt;
&lt;li&gt;system prompt&lt;/li&gt;
&lt;li&gt;injected memory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t inspect the final prompt state, you’re debugging blind.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Separate session memory from durable memory from retrieval
&lt;/h3&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the agent forget something from this run?&lt;/li&gt;
&lt;li&gt;Did it fail to load something from prior runs?&lt;/li&gt;
&lt;li&gt;Did retrieval miss the source doc entirely?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are 3 different incidents.&lt;/p&gt;

&lt;p&gt;Treat them that way.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add keyword search for exact-match terms
&lt;/h3&gt;

&lt;p&gt;If the bug involves IDs, SKUs, policy names, or literal strings, semantic retrieval alone is a bad bet.&lt;/p&gt;

&lt;p&gt;Use hybrid retrieval.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Rerank before switching models
&lt;/h3&gt;

&lt;p&gt;Bad top-k results poison everything downstream.&lt;/p&gt;

&lt;p&gt;Reranking is often cheaper and more effective than migrating from one frontier model to another because you’re fixing the input, not arguing about benchmark deltas.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Test prompt position, not just prompt content
&lt;/h3&gt;

&lt;p&gt;Move the critical fact.&lt;/p&gt;

&lt;p&gt;Seriously.&lt;/p&gt;

&lt;p&gt;If it’s buried in the middle of a long prompt, put it near the end or surface it in a structured summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Check whether your corpus is small enough to skip RAG
&lt;/h3&gt;

&lt;p&gt;If the full knowledge base fits comfortably in context, try the simpler architecture first.&lt;/p&gt;

&lt;p&gt;Fewer moving parts means fewer ways to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick local experiment: inspect exact-match retrieval behavior
&lt;/h2&gt;

&lt;p&gt;If you want a simple sanity check, compare semantic-only retrieval against a hybrid strategy for exact identifiers.&lt;/p&gt;

&lt;p&gt;A toy example in Python:&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;queries&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;What is the refund window for SKU-8472?&lt;/span&gt;&lt;span class="sh"&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;Find policy for order ID ORD-19384&lt;/span&gt;&lt;span class="sh"&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;What does workflow billing_reversal_v2 do?&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;span class="n"&gt;exact_terms&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;SKU-8472&lt;/span&gt;&lt;span class="sh"&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;ORD-19384&lt;/span&gt;&lt;span class="sh"&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;billing_reversal_v2&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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exact_terms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;query=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="si"&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;print&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;must not lose exact term: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="si"&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;print&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks trivial, but it’s the whole point.&lt;/p&gt;

&lt;p&gt;If your retrieval layer can’t preserve exact identifiers reliably, the model is being asked to reason from incomplete evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table that changed how I think about agent reliability
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Retrieval method&lt;/th&gt;
&lt;th&gt;Reported failure reduction&lt;/th&gt;
&lt;th&gt;Needs reranking/BM25&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard RAG&lt;/td&gt;
&lt;td&gt;Basic chunking plus semantic retrieval&lt;/td&gt;
&lt;td&gt;No specific reduction cited here&lt;/td&gt;
&lt;td&gt;Usually yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Contextual Retrieval&lt;/td&gt;
&lt;td&gt;Contextualized chunks plus semantic retrieval and Contextual BM25&lt;/td&gt;
&lt;td&gt;49% fewer failed retrievals&lt;/td&gt;
&lt;td&gt;Yes, benefits strongly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contextual Retrieval plus reranking&lt;/td&gt;
&lt;td&gt;Contextualized retrieval with reranked results&lt;/td&gt;
&lt;td&gt;67% fewer failed retrievals&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is the argument.&lt;/p&gt;

&lt;p&gt;A lot of agent unreliability is not model IQ.&lt;/p&gt;

&lt;p&gt;It’s underbuilt retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more when you run agents at scale
&lt;/h2&gt;

&lt;p&gt;This gets more painful when agents run continuously in automations.&lt;/p&gt;

&lt;p&gt;If you have workflows in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;n8n&lt;/li&gt;
&lt;li&gt;Make&lt;/li&gt;
&lt;li&gt;Zapier&lt;/li&gt;
&lt;li&gt;OpenAI-compatible custom stacks&lt;/li&gt;
&lt;li&gt;internal cron-driven agents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...then retrieval misses turn into repeated production failures.&lt;/p&gt;

&lt;p&gt;And if you’re paying per token, debugging gets even more annoying because every retry, replay, and prompt experiment has a visible cost attached to it.&lt;/p&gt;

&lt;p&gt;That’s one reason I think predictable API infrastructure matters for agent teams.&lt;/p&gt;

&lt;p&gt;If you’re iterating on retrieval, memory, reranking, and long-running automations, flat-cost OpenAI-compatible compute is a lot easier to work with than watching token spend while trying to fix reliability.&lt;/p&gt;

&lt;p&gt;That’s the appeal of Standard Compute: same OpenAI-compatible API shape, but built for teams running agents and automations all day without per-token anxiety.&lt;/p&gt;

&lt;p&gt;When you’re testing retrieval fixes, prompt changes, and multi-step workflows repeatedly, predictable cost matters almost as much as model quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  My current rule
&lt;/h2&gt;

&lt;p&gt;When an agent fails an "obvious" task, I no longer start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;maybe GPT-5 is worse at this&lt;/li&gt;
&lt;li&gt;maybe Claude should replace it&lt;/li&gt;
&lt;li&gt;maybe we need a bigger model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what did retrieval return?&lt;/li&gt;
&lt;li&gt;where did the fact appear in context?&lt;/li&gt;
&lt;li&gt;did exact-match search exist?&lt;/li&gt;
&lt;li&gt;was reranking applied?&lt;/li&gt;
&lt;li&gt;did the agent actually see the right thing in usable form?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That question is less fun than debating models.&lt;/p&gt;

&lt;p&gt;It’s also the one that usually fixes the bug.&lt;/p&gt;

&lt;p&gt;If your agent keeps failing in dumb ways, there’s a good chance the model isn’t the first thing you should blame.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>rag</category>
    </item>
    <item>
      <title>I finally figured out what people mean by the best uncensored LLM after watching 17 agent workflows fail</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:09:39 +0000</pubDate>
      <link>https://dev.to/lars_winstand/i-finally-figured-out-what-people-mean-by-the-best-uncensored-llm-after-watching-17-agent-workflows-242h</link>
      <guid>https://dev.to/lars_winstand/i-finally-figured-out-what-people-mean-by-the-best-uncensored-llm-after-watching-17-agent-workflows-242h</guid>
      <description>&lt;p&gt;A few months ago I noticed something weird about how teams ask for the “best uncensored LLM.”&lt;/p&gt;

&lt;p&gt;The request sounds dramatic.&lt;/p&gt;

&lt;p&gt;The actual production problem usually isn’t.&lt;/p&gt;

&lt;p&gt;Nobody on a serious team is saying, “I need a model that will do literally anything.”&lt;/p&gt;

&lt;p&gt;What they’re really saying is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;my security agent refused to summarize a phishing kit&lt;/li&gt;
&lt;li&gt;my moderation pipeline refused to classify explicit text&lt;/li&gt;
&lt;li&gt;my coding agent bailed when the prompt mentioned Bash, scraping, auth flows, or packet capture&lt;/li&gt;
&lt;li&gt;my n8n or Make automation got weirdly cautious right when it needed to do real ops work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a completely different problem.&lt;/p&gt;

&lt;p&gt;And once you frame it that way, a lot of bad model choices suddenly make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most teams asking for an uncensored model are really asking for fewer false refusals
&lt;/h2&gt;

&lt;p&gt;This is the quiet version of the conversation.&lt;/p&gt;

&lt;p&gt;If you run internal security workflows, you do not want your model moralizing when you need it to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;summarize malware behavior&lt;/li&gt;
&lt;li&gt;extract IOCs from a phishing kit&lt;/li&gt;
&lt;li&gt;explain an exploit proof of concept&lt;/li&gt;
&lt;li&gt;compare two suspicious payloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run moderation, the model has to actually read ugly content.&lt;/p&gt;

&lt;p&gt;If your pipeline is classifying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sexual content&lt;/li&gt;
&lt;li&gt;hate speech&lt;/li&gt;
&lt;li&gt;harassment&lt;/li&gt;
&lt;li&gt;violent threats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then refusing to process the raw text is not safety.&lt;/p&gt;

&lt;p&gt;It’s just failure with better branding.&lt;/p&gt;

&lt;p&gt;And coding agents hit this wall all the time.&lt;/p&gt;

&lt;p&gt;A normal internal automation might need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;write a Playwright script&lt;/li&gt;
&lt;li&gt;generate a credential rotation job&lt;/li&gt;
&lt;li&gt;build a SQL migration helper&lt;/li&gt;
&lt;li&gt;parse packet captures&lt;/li&gt;
&lt;li&gt;inspect logs containing slurs or threats&lt;/li&gt;
&lt;li&gt;reproduce an auth bypass bug so engineering can patch it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is edgy.&lt;/p&gt;

&lt;p&gt;But some APIs get twitchy the second the prompt includes words like &lt;code&gt;bypass&lt;/code&gt;, &lt;code&gt;exploit&lt;/code&gt;, &lt;code&gt;scrape&lt;/code&gt;, or &lt;code&gt;shell&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So when developers search for an uncensored LLM API, what they often mean is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;please stop refusing legitimate internal work just because the text looks scary&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction matters a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workloads that actually need lower-refusal behavior
&lt;/h2&gt;

&lt;p&gt;Not every workflow needs this.&lt;/p&gt;

&lt;p&gt;Most don’t.&lt;/p&gt;

&lt;p&gt;But the ones that do really do.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Security triage and exploit reproduction
&lt;/h3&gt;

&lt;p&gt;This is the clearest case.&lt;/p&gt;

&lt;p&gt;A security team might need a model to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;summarize malware behavior&lt;/li&gt;
&lt;li&gt;extract domains, hashes, or indicators from a phishing kit&lt;/li&gt;
&lt;li&gt;explain an exploit PoC&lt;/li&gt;
&lt;li&gt;reproduce a bug internally so it can be patched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frontier models like GPT-5 or Claude Opus can be excellent at reasoning.&lt;/p&gt;

&lt;p&gt;That’s not the issue.&lt;/p&gt;

&lt;p&gt;The issue is provider policy.&lt;/p&gt;

&lt;p&gt;If your workflow lives near security-sensitive content every day, a lower-refusal open-weight model can be more useful than a stronger closed model that keeps slamming the brakes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Moderation and trust-and-safety pipelines
&lt;/h3&gt;

&lt;p&gt;This one sounds ironic because it is a safety use case.&lt;/p&gt;

&lt;p&gt;But it still benefits from lower-refusal behavior.&lt;/p&gt;

&lt;p&gt;If you need to classify explicit or abusive user-generated content at scale, over-refusal is counterproductive.&lt;/p&gt;

&lt;p&gt;The safer pattern is usually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;let the model read the ugly content&lt;/li&gt;
&lt;li&gt;keep the workflow contained&lt;/li&gt;
&lt;li&gt;add logging and downstream policy checks&lt;/li&gt;
&lt;li&gt;restrict tool access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model should process the mess.&lt;/p&gt;

&lt;p&gt;Your system should contain the risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Internal coding agents and automations
&lt;/h3&gt;

&lt;p&gt;This is where the “uncensored” label gets abused, but there is a real issue underneath it.&lt;/p&gt;

&lt;p&gt;An internal agent in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;n8n&lt;/li&gt;
&lt;li&gt;Make&lt;/li&gt;
&lt;li&gt;Zapier&lt;/li&gt;
&lt;li&gt;OpenClaw&lt;/li&gt;
&lt;li&gt;a custom Python runner&lt;/li&gt;
&lt;li&gt;an internal Node service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;might need to generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bash scripts&lt;/li&gt;
&lt;li&gt;scraping logic&lt;/li&gt;
&lt;li&gt;packet parsers&lt;/li&gt;
&lt;li&gt;credential rotation jobs&lt;/li&gt;
&lt;li&gt;debugging helpers for auth flows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are normal tasks.&lt;/p&gt;

&lt;p&gt;If your model starts refusing every time shell access or security-sensitive APIs show up, your agent fails exactly where ops teams need it most.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question is not “which model is least censored?”
&lt;/h2&gt;

&lt;p&gt;The better question is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;which exact task is getting falsely refused?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question forces better decisions.&lt;/p&gt;

&lt;p&gt;Because sometimes the answer is “switch models.”&lt;/p&gt;

&lt;p&gt;And sometimes the answer is “your workflow is sloppy.”&lt;/p&gt;

&lt;h2&gt;
  
  
  API compatibility matters more than rebellious branding
&lt;/h2&gt;

&lt;p&gt;This is the part a lot of teams miss.&lt;/p&gt;

&lt;p&gt;Nobody wants to rebuild an agent stack just to test one model with fewer refusals.&lt;/p&gt;

&lt;p&gt;That’s why OpenAI-compatible APIs matter so much.&lt;/p&gt;

&lt;p&gt;If your app already speaks the OpenAI API shape, you can swap the backend without rewriting your whole system.&lt;/p&gt;

&lt;p&gt;That is why tools like &lt;code&gt;vLLM&lt;/code&gt; are so useful for this category.&lt;/p&gt;

&lt;p&gt;You can serve open-weight models behind an OpenAI-compatible endpoint and test them in your real workflow instead of arguing in Slack about screenshots and benchmarks.&lt;/p&gt;

&lt;p&gt;A minimal example looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve meta-llama/Llama-3.1-70B-Instruct
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point your existing client at the new base URL:&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;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8000/v1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dummy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And your app code may barely change:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dummy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;meta-llama/Llama-3.1-70B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&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;system&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;You are a security analysis assistant.&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;Summarize this phishing kit and extract indicators.&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;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&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;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That kind of swap is a big deal.&lt;/p&gt;

&lt;p&gt;Because if your n8n flow or Python worker can point at a different &lt;code&gt;base_url&lt;/code&gt;, you can test behavior inside the real automation.&lt;/p&gt;

&lt;p&gt;That beats theoretical debate every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3 practical architecture options
&lt;/h2&gt;

&lt;p&gt;Here’s the cleanest way I’ve found to think about it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What you actually get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open-weight model via vLLM&lt;/td&gt;
&lt;td&gt;Lower refusal potential depending on model choice, OpenAI-compatible serving, but you have to self-host and build your own guardrails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed API like OpenAI, Anthropic, or xAI&lt;/td&gt;
&lt;td&gt;Strong reasoning and coding quality, managed infrastructure, but provider-enforced policies limit flexibility on sensitive workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router layer with an OpenAI-compatible endpoint&lt;/td&gt;
&lt;td&gt;Easy model swapping and workload-based routing, but still constrained by provider policy unless you route to open or self-hosted models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My opinion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if your problem is occasional false refusals in a mostly normal coding workflow, routing between GPT-5, Claude Opus 4.6, and Grok 4.20 is often enough&lt;/li&gt;
&lt;li&gt;if your problem is persistent refusal on malware analysis, exploit reproduction, or explicit-content moderation, you probably need an open-weight route somewhere in the stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s not ideology.&lt;/p&gt;

&lt;p&gt;That’s just operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lot of teams do not need an uncensored model. They need better workflow design.
&lt;/h2&gt;

&lt;p&gt;This is the part people hate hearing.&lt;/p&gt;

&lt;p&gt;I’ve seen teams blame the model when the actual issue was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vague prompts&lt;/li&gt;
&lt;li&gt;junk retrieval context&lt;/li&gt;
&lt;li&gt;broad tool permissions&lt;/li&gt;
&lt;li&gt;no separation between analysis and action&lt;/li&gt;
&lt;li&gt;no logging before side effects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those cases, switching to a looser model just gives you a more confident failure mode.&lt;/p&gt;

&lt;p&gt;A boring workflow cleanup often fixes more than people expect.&lt;/p&gt;

&lt;p&gt;Here’s the checklist I usually recommend first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;tighten the system prompt&lt;/li&gt;
&lt;li&gt;separate classification from action-taking&lt;/li&gt;
&lt;li&gt;restrict tool permissions by task&lt;/li&gt;
&lt;li&gt;scope retrieval to only relevant docs&lt;/li&gt;
&lt;li&gt;log model decisions before external side effects&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A quick example.&lt;/p&gt;

&lt;p&gt;Bad pattern:&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="c1"&gt;# One agent can read tickets, write scripts, execute shell commands,
# and call external APIs with almost no boundaries.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better pattern:&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="c1"&gt;# Agent 1: classify issue
# Agent 2: propose remediation steps
# Agent 3: execute only approved, scoped actions
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That split alone removes a surprising amount of chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lower refusal does not automatically mean better for production
&lt;/h2&gt;

&lt;p&gt;This is where the “uncensored” conversation usually gets unserious.&lt;/p&gt;

&lt;p&gt;Lower refusal rates are useful for some workloads.&lt;/p&gt;

&lt;p&gt;They are not automatically good.&lt;/p&gt;

&lt;p&gt;If your agent touches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;customer data&lt;/li&gt;
&lt;li&gt;regulated workflows&lt;/li&gt;
&lt;li&gt;autonomous tools&lt;/li&gt;
&lt;li&gt;production systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then stricter provider behavior may be a feature, not a bug.&lt;/p&gt;

&lt;p&gt;A model that willingly helps with exploit reproduction in a security lab may also be more willing to generate dangerous instructions in the wrong context.&lt;/p&gt;

&lt;p&gt;A moderation model that reliably processes explicit content may also mishandle regulated material if your downstream checks are weak.&lt;/p&gt;

&lt;p&gt;So the real evaluation questions are:&lt;/p&gt;

&lt;h3&gt;
  
  
  Which workload needs lower refusal?
&lt;/h3&gt;

&lt;p&gt;Be specific.&lt;/p&gt;

&lt;p&gt;Security triage is not customer support.&lt;/p&gt;

&lt;p&gt;Malware analysis is not a healthcare assistant.&lt;/p&gt;

&lt;h3&gt;
  
  
  What containment do you have?
&lt;/h3&gt;

&lt;p&gt;You want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;logging&lt;/li&gt;
&lt;li&gt;approval gates&lt;/li&gt;
&lt;li&gt;read-only tools where possible&lt;/li&gt;
&lt;li&gt;scoped retrieval&lt;/li&gt;
&lt;li&gt;audit trails&lt;/li&gt;
&lt;li&gt;post-generation policy checks&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Can you swap models without rewriting everything?
&lt;/h3&gt;

&lt;p&gt;This is why OpenAI-compatible endpoints matter so much.&lt;/p&gt;

&lt;p&gt;If you can route by workload, you don’t need one model to be perfect at everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup I keep seeing work
&lt;/h2&gt;

&lt;p&gt;The smartest teams are usually not betting everything on one “uncensored” model.&lt;/p&gt;

&lt;p&gt;They do something much more boring and much more effective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use stricter closed models where policy alignment is helpful&lt;/li&gt;
&lt;li&gt;use lower-refusal open models only where legitimate internal work keeps getting blocked&lt;/li&gt;
&lt;li&gt;keep everything behind the same API shape so routing is easy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the grown-up version.&lt;/p&gt;

&lt;p&gt;And honestly, it’s also the only version that survives contact with production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Standard Compute fits
&lt;/h2&gt;

&lt;p&gt;This is exactly why I like OpenAI-compatible router layers for agent workloads.&lt;/p&gt;

&lt;p&gt;If your team is already running automations in n8n, Make, Zapier, OpenClaw, or custom code, the best setup is usually the one that lets you swap models without rewriting clients.&lt;/p&gt;

&lt;p&gt;That’s the useful part of Standard Compute.&lt;/p&gt;

&lt;p&gt;It gives you an OpenAI-compatible endpoint with flat monthly pricing, so you can route workloads across models like GPT-5.4, Claude Opus 4.6, and Grok 4.20 without doing pricing math every time an agent loops harder than expected.&lt;/p&gt;

&lt;p&gt;That matters a lot for automations.&lt;/p&gt;

&lt;p&gt;Especially when your real problem is not just refusals.&lt;/p&gt;

&lt;p&gt;It’s also cost predictability.&lt;/p&gt;

&lt;p&gt;Per-token billing makes teams weirdly conservative with agents. They shorten prompts too aggressively, avoid useful retries, and babysit workflows that should just run.&lt;/p&gt;

&lt;p&gt;Flat-rate compute changes that behavior.&lt;/p&gt;

&lt;p&gt;If you’re building agents that run all day, that pricing model is often as important as model quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  My practical takeaway
&lt;/h2&gt;

&lt;p&gt;If you’re searching for the best uncensored LLM, stop asking which model has the loudest anti-censorship branding.&lt;/p&gt;

&lt;p&gt;Ask which exact task is failing.&lt;/p&gt;

&lt;p&gt;If it’s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;browser automation&lt;/li&gt;
&lt;li&gt;SQL migrations&lt;/li&gt;
&lt;li&gt;packet parsing&lt;/li&gt;
&lt;li&gt;internal shell scripting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then fix prompts and permissions first.&lt;/p&gt;

&lt;p&gt;If it’s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;malware triage&lt;/li&gt;
&lt;li&gt;exploit reproduction for patching&lt;/li&gt;
&lt;li&gt;explicit-content classification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then yes, a lower-refusal open-weight model served through something like &lt;code&gt;vLLM&lt;/code&gt; may be the right answer.&lt;/p&gt;

&lt;p&gt;Just don’t confuse “less likely to refuse” with “better for production.”&lt;/p&gt;

&lt;p&gt;The winning setup is usually the one that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fits your existing agent stack&lt;/li&gt;
&lt;li&gt;speaks OpenAI-compatible API&lt;/li&gt;
&lt;li&gt;handles the ugly parts of your workload&lt;/li&gt;
&lt;li&gt;gives you enough guardrails that Monday morning doesn’t start with an incident review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s a much less exciting answer than “find the most uncensored model.”&lt;/p&gt;

&lt;p&gt;It’s also the answer that actually works.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>api</category>
      <category>devops</category>
    </item>
    <item>
      <title>My AI cron job looked cheap at 5 runs/day. At 96 runs/day it turned into 1,728 model calls</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:08:25 +0000</pubDate>
      <link>https://dev.to/lars_winstand/my-ai-cron-job-looked-cheap-at-5-runsday-at-96-runsday-it-turned-into-1728-model-calls-4023</link>
      <guid>https://dev.to/lars_winstand/my-ai-cron-job-looked-cheap-at-5-runsday-at-96-runsday-it-turned-into-1728-model-calls-4023</guid>
      <description>&lt;p&gt;I learned this the annoying way: the expensive part of an AI cron job usually isn't the prompt.&lt;/p&gt;

&lt;p&gt;It's the production behavior around it.&lt;/p&gt;

&lt;p&gt;A workflow that looks basically free in testing can turn into a small army of billable model calls once you add retries, polling, multi-step chains, and separate environments.&lt;/p&gt;

&lt;p&gt;I had an n8n cron running every 15 minutes. In testing, it seemed harmless. Then I turned on retries, added a Claude Opus 4.6 summary step after a GPT-5.4 extraction step, mirrored the workflow in staging and prod, and suddenly my neat little automation had very different economics.&lt;/p&gt;

&lt;p&gt;The prompt wasn't the problem.&lt;/p&gt;

&lt;p&gt;The architecture was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The testing version lied
&lt;/h2&gt;

&lt;p&gt;My original mental model looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron -&amp;gt; fetch data -&amp;gt; call model -&amp;gt; done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in testing, that was kind of true.&lt;/p&gt;

&lt;p&gt;I ran it manually a few times. Maybe let the cron fire 5 times in a day. Cost looked tiny. Everyone moved on.&lt;/p&gt;

&lt;p&gt;Production looked more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron -&amp;gt; fetch records -&amp;gt; GPT-5.4 classify -&amp;gt; Claude Opus 4.6 summarize
     -&amp;gt; retry on timeout -&amp;gt; poll downstream status -&amp;gt; send output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then there were three copies of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev
staging
prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is how "one workflow" becomes a swarm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math that changed the conversation
&lt;/h2&gt;

&lt;p&gt;Here's the simple version.&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;Calls per day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing assumption: 5 runs/day × 1 model call&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real production schedule: 96 runs/day × 3 model calls&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add 2 retries across the workflow&lt;/td&gt;
&lt;td&gt;576&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiply across dev, staging, and prod&lt;/td&gt;
&lt;td&gt;1,728&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 1,728 number is not some nightmare edge case.&lt;/p&gt;

&lt;p&gt;It's very normal if you build scheduled automations the way they're actually supposed to be built.&lt;/p&gt;

&lt;p&gt;Retries are normal.&lt;br&gt;
Polling is normal.&lt;br&gt;
Separate environments are normal.&lt;br&gt;
Multi-step pipelines are normal.&lt;/p&gt;

&lt;p&gt;The spreadsheet was wrong because it modeled the happy path, not the real system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why AI cron jobs blow up in production
&lt;/h2&gt;

&lt;p&gt;Because production is where all the reliability features show up.&lt;/p&gt;

&lt;p&gt;Here's the kind of stack that quietly multiplies usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;n8n cron trigger every 15 minutes&lt;/li&gt;
&lt;li&gt;webhook or HTTP step to pull new records&lt;/li&gt;
&lt;li&gt;GPT-5.4 classification call&lt;/li&gt;
&lt;li&gt;Claude Opus 4.6 summary call&lt;/li&gt;
&lt;li&gt;retry queue when the upstream API times out&lt;/li&gt;
&lt;li&gt;polling step to wait for downstream completion&lt;/li&gt;
&lt;li&gt;separate dev, staging, and prod environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is weird.&lt;/p&gt;

&lt;p&gt;That's just what happens when a workflow graduates from demo to useful.&lt;/p&gt;

&lt;p&gt;If you're running Make scenarios, Zapier schedules, OpenClaw agents, or custom Python workers on cron, the same pattern shows up fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  A quick way to estimate the real cost
&lt;/h2&gt;

&lt;p&gt;If you're still estimating AI automation cost with "how much does one prompt cost?", you're probably undercounting by a lot.&lt;/p&gt;

&lt;p&gt;A better back-of-the-napkin formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total_daily_calls = scheduled_runs_per_day
                  × model_calls_per_run
                  × retry_multiplier
                  × environment_count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;96 runs/day
× 3 model calls/run
× 2 retry multiplier
× 3 environments
= 1,728 calls/day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to make this concrete in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;estimateDailyCalls&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;runsPerDay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;modelCallsPerRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;retryMultiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;environments&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;runsPerDay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;modelCallsPerRun&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;retryMultiplier&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;environments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyCalls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateDailyCalls&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;runsPerDay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;modelCallsPerRun&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="na"&gt;retryMultiplier&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="na"&gt;environments&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="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dailyCalls&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1728&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you're more shell-script-brained:&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="nv"&gt;runs_per_day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;96
&lt;span class="nv"&gt;model_calls_per_run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;span class="nv"&gt;retry_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;span class="nv"&gt;environments&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;runs_per_day &lt;span class="o"&gt;*&lt;/span&gt; model_calls_per_run &lt;span class="o"&gt;*&lt;/span&gt; retry_multiplier &lt;span class="o"&gt;*&lt;/span&gt; environments&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="c"&gt;# 1728&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This still won't be perfect, but it's a lot closer to reality than pricing one prompt in isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive part is usually not the first prompt
&lt;/h2&gt;

&lt;p&gt;This is the part I think a lot of teams miss.&lt;/p&gt;

&lt;p&gt;People spend hours trying to shave 8% off a prompt while ignoring the bigger multiplier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one model call becomes three&lt;/li&gt;
&lt;li&gt;one run becomes 96 runs/day&lt;/li&gt;
&lt;li&gt;one clean execution becomes retries + polling&lt;/li&gt;
&lt;li&gt;one environment becomes dev + staging + prod&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've seen workflows where GPT-5.4 does extraction, Claude Opus 4.6 does summarization, and Grok 4.20 gets pulled in for a second pass or rewrite.&lt;/p&gt;

&lt;p&gt;That can absolutely improve quality.&lt;/p&gt;

&lt;p&gt;It also means your "simple automation" is now several billable inference steps deep before the retry queue even wakes up.&lt;/p&gt;

&lt;p&gt;That's not bad engineering. Sometimes it's the right architecture.&lt;/p&gt;

&lt;p&gt;But it does mean usage-based pricing gets painful fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem with per-token pricing for scheduled workflows
&lt;/h2&gt;

&lt;p&gt;Per-token pricing is fine when you're experimenting.&lt;/p&gt;

&lt;p&gt;It's much worse when you're running always-on agents and scheduled automations that are supposed to be boring and reliable.&lt;/p&gt;

&lt;p&gt;Because every sensible production improvement makes cost forecasting worse.&lt;/p&gt;

&lt;p&gt;Add retries? More spend.&lt;/p&gt;

&lt;p&gt;Add staging? More spend.&lt;/p&gt;

&lt;p&gt;Increase polling because a partner API is flaky? More spend.&lt;/p&gt;

&lt;p&gt;Split one prompt into extraction + summarization because output quality improved? More spend.&lt;/p&gt;

&lt;p&gt;That is a bad incentive structure.&lt;/p&gt;

&lt;p&gt;Better engineering should not make the bill harder to predict.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The shift for me was treating AI automation like infrastructure, not like a one-off API experiment.&lt;/p&gt;

&lt;p&gt;That changed how I thought about pricing.&lt;/p&gt;

&lt;p&gt;For cron-heavy workflows, flat-rate compute is just a better fit than per-token billing.&lt;/p&gt;

&lt;p&gt;If you're running n8n, Make, Zapier, OpenClaw, or custom cron jobs through an OpenAI-compatible API, you want the reliability features turned on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;polling&lt;/li&gt;
&lt;li&gt;background processing&lt;/li&gt;
&lt;li&gt;multiple environments&lt;/li&gt;
&lt;li&gt;multi-step model chains&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not want engineers doing token math every week because the workflow is finally behaving like production software.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;If your code already talks to an OpenAI-compatible API, swapping providers should not require a rewrite.&lt;/p&gt;

&lt;p&gt;Example with the OpenAI SDK pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STANDARD_COMPUTE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.standardcompute.com/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-5.4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&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;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classify incoming support tickets.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Payment failed after checkout&lt;/span&gt;&lt;span class="dl"&gt;"&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;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your workflow is in n8n, Make, Zapier, or a custom worker, the point is the same: keep the workflow architecture you need, without turning reliability into a budgeting problem.&lt;/p&gt;

&lt;p&gt;Standard Compute is built for exactly this kind of workload: AI agents, automations, scheduled jobs, and OpenAI-compatible integrations that need predictable monthly cost instead of surprise usage bills.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical checks before you ship an AI cron job
&lt;/h2&gt;

&lt;p&gt;If I were reviewing one of these workflows now, I'd ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How many scheduled runs happen per day in production?&lt;/li&gt;
&lt;li&gt;How many model calls happen per run, including summary/rewrite/classification steps?&lt;/li&gt;
&lt;li&gt;What is the retry behavior under partial failure?&lt;/li&gt;
&lt;li&gt;Are there polling loops?&lt;/li&gt;
&lt;li&gt;How many environments are active?&lt;/li&gt;
&lt;li&gt;Are there hidden fan-out steps for batches or records?&lt;/li&gt;
&lt;li&gt;Does the pricing model still make sense after all of that?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you can't answer those, the workflow is probably more expensive than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  My actual takeaway
&lt;/h2&gt;

&lt;p&gt;Testing lies by omission.&lt;/p&gt;

&lt;p&gt;Production tells the truth.&lt;/p&gt;

&lt;p&gt;And the truth is that always-on AI automation does not stay cheap just because the first five runs were cheap.&lt;/p&gt;

&lt;p&gt;If your workflow is scheduled, retried, polled, duplicated across environments, and expected to run 24/7 without babysitting, then the billing model matters as much as the prompt.&lt;/p&gt;

&lt;p&gt;That was the part I missed at first.&lt;/p&gt;

&lt;p&gt;If you're building cron-heavy AI workflows and you're tired of per-token pricing turning normal engineering into a finance problem, Standard Compute is worth a look: &lt;a href="https://standardcompute.com" rel="noopener noreferrer"&gt;https://standardcompute.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>devops</category>
      <category>n8n</category>
    </item>
    <item>
      <title>My AI agent self-healing fix was embarrassingly simple once I stopped retrying the whole thing</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 22:08:43 +0000</pubDate>
      <link>https://dev.to/lars_winstand/my-ai-agent-self-healing-fix-was-embarrassingly-simple-once-i-stopped-retrying-the-whole-thing-3a42</link>
      <guid>https://dev.to/lars_winstand/my-ai-agent-self-healing-fix-was-embarrassingly-simple-once-i-stopped-retrying-the-whole-thing-3a42</guid>
      <description>&lt;h1&gt;
  
  
  My AI agent self-healing fix was embarrassingly simple once I stopped retrying the whole thing
&lt;/h1&gt;

&lt;p&gt;I finally snapped after watching an agent die on step 8 of 12, then restart from step 1 like it had learned absolutely nothing.&lt;/p&gt;

&lt;p&gt;The workflow was not exotic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pull leads from a form&lt;/li&gt;
&lt;li&gt;enrich them&lt;/li&gt;
&lt;li&gt;summarize the account&lt;/li&gt;
&lt;li&gt;draft outreach&lt;/li&gt;
&lt;li&gt;push results into a CRM&lt;/li&gt;
&lt;li&gt;notify the team in Slack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure was dumb: one HTTP timeout during enrichment.&lt;/p&gt;

&lt;p&gt;But because I had wired the workflow to retry the entire run, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;re-did earlier model calls&lt;/li&gt;
&lt;li&gt;re-fetched records I already had&lt;/li&gt;
&lt;li&gt;almost created duplicate CRM updates&lt;/li&gt;
&lt;li&gt;burned compute on work that had already succeeded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In n8n, that meant another full execution.&lt;/p&gt;

&lt;p&gt;In LangGraph, that meant replay unless I handled state correctly.&lt;/p&gt;

&lt;p&gt;Either way, I was paying for my own bad design.&lt;/p&gt;

&lt;p&gt;I thought the answer would be better prompts.&lt;/p&gt;

&lt;p&gt;It was not.&lt;/p&gt;

&lt;p&gt;The answer was teaching the agent how to resume.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The best ai agent self-healing pattern is not a smarter prompt. It is durable execution: save explicit state, retry only failure-prone steps, and resume with a stable execution ID.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are building long-running agents with n8n, LangGraph, Temporal, Make, Zapier, or custom workers, this is the line between "kind of works" and "survives production."&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem with full-workflow retries
&lt;/h2&gt;

&lt;p&gt;Full-workflow retries feel safe when the workflow is tiny.&lt;/p&gt;

&lt;p&gt;They become a disaster when your agent spans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiple LLM calls&lt;/li&gt;
&lt;li&gt;external APIs&lt;/li&gt;
&lt;li&gt;human approvals&lt;/li&gt;
&lt;li&gt;writes into systems that really do not like duplicates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My original logic was painfully common:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;That is not resilience.&lt;/p&gt;

&lt;p&gt;That is replay.&lt;/p&gt;

&lt;p&gt;And replay causes a bunch of avoidable problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;successful LLM calls get repeated&lt;/li&gt;
&lt;li&gt;tool calls hit external systems again&lt;/li&gt;
&lt;li&gt;token usage and latency go up for no reason&lt;/li&gt;
&lt;li&gt;logs get noisier on every retry&lt;/li&gt;
&lt;li&gt;one flaky step turns into a full pipeline failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Clearbit times out, or Salesforce rate-limits you, or a webhook returns 502, a better system prompt does not help.&lt;/p&gt;

&lt;p&gt;This is why I stopped asking:&lt;/p&gt;

&lt;p&gt;"How do I make the model less fragile?"&lt;/p&gt;

&lt;p&gt;And started asking:&lt;/p&gt;

&lt;p&gt;"What already succeeded, and how do I avoid doing it again?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stable execution IDs + explicit state + step-level retries
&lt;/h2&gt;

&lt;p&gt;What finally worked was boring in the best way.&lt;/p&gt;

&lt;p&gt;I gave every run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a stable &lt;code&gt;execution_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;explicit persisted state after each meaningful step&lt;/li&gt;
&lt;li&gt;hard retry boundaries around the steps that actually fail in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state shape was not fancy. It looked like normal application plumbing.&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"execution_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead_9f3d7c2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summarize_account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lead_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lead_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"artifacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enrichment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"employees"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"B2B SaaS company expanding sales ops"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"side_effects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"crm_upserted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"slack_notified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idempotency_keys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"crm_write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crm:lead_123:v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"slack_post"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slack:lead_123:v1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"step"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enrichment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTTP timeout"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;That one change killed most of the chaos.&lt;/p&gt;

&lt;p&gt;The new rule became:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retry model or HTTP calls at the step level&lt;/li&gt;
&lt;li&gt;never repeat a side effect unless the step is idempotent&lt;/li&gt;
&lt;li&gt;resume from the last completed checkpoint using the same execution ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is what self-healing looked like in practice.&lt;/p&gt;

&lt;p&gt;Not the agent becoming smarter.&lt;/p&gt;

&lt;p&gt;The agent becoming less forgetful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What step-level retry logic actually looks like
&lt;/h2&gt;

&lt;p&gt;Here is the pattern in plain TypeScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WorkflowState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;executionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;currentStep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;sideEffects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;crmUpserted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
    &lt;span class="na"&gt;slackNotified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runLeadWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enrichment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;retryStep&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;enrichLead&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enriched&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enriched&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;retryStep&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;summarizeAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enrichment&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crmUpserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertCRM&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`crm:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;executionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crmUpserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crm_written&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crm_written&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackNotified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postToSlack&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Lead processed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`slack:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;executionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sideEffects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackNotified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryStep&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;attempt&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;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;lastError&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the syntax.&lt;/p&gt;

&lt;p&gt;It is the boundary.&lt;/p&gt;

&lt;p&gt;Each expensive or failure-prone step gets its own retry policy.&lt;/p&gt;

&lt;p&gt;Each successful step gets checkpointed.&lt;/p&gt;

&lt;p&gt;External writes are protected with idempotency keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resume, don’t restart
&lt;/h2&gt;

&lt;p&gt;The key mental shift is this:&lt;/p&gt;

&lt;p&gt;Separate workflow state from step execution.&lt;/p&gt;

&lt;p&gt;Once I did that, the tooling got much easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangGraph: reuse &lt;code&gt;thread_id&lt;/code&gt; and persist graph state
&lt;/h2&gt;

&lt;p&gt;LangGraph gets a lot better once you stop treating every run like a fresh conversation.&lt;/p&gt;

&lt;p&gt;If you use a checkpointer and keep the same &lt;code&gt;thread_id&lt;/code&gt;, you can resume from existing state instead of replaying the graph from the top.&lt;/p&gt;

&lt;p&gt;Conceptually:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.checkpoint.memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemorySaver&lt;/span&gt;

&lt;span class="n"&gt;checkpointer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemorySaver&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;config&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;configurable&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&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;lead-123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;

&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lead_id&lt;/span&gt;&lt;span class="sh"&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;lead-123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# later, after failure or human review
&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resume&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="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you pause with &lt;code&gt;interrupt()&lt;/code&gt; for human review, you can continue with &lt;code&gt;Command(resume=...)&lt;/code&gt; instead of rerunning the whole graph.&lt;/p&gt;

&lt;p&gt;That is a much better model for real automations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Temporal: retry Activities, not the whole business process
&lt;/h2&gt;

&lt;p&gt;Temporal is probably the cleanest expression of this pattern.&lt;/p&gt;

&lt;p&gt;The Workflow keeps deterministic state.&lt;/p&gt;

&lt;p&gt;Activities do the flaky work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP calls&lt;/li&gt;
&lt;li&gt;database writes&lt;/li&gt;
&lt;li&gt;LLM tool invocations&lt;/li&gt;
&lt;li&gt;queue operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split matters.&lt;/p&gt;

&lt;p&gt;If the enrichment API times out, retry the enrichment Activity.&lt;/p&gt;

&lt;p&gt;Do not rerun the entire lead-processing workflow.&lt;/p&gt;

&lt;p&gt;Pseudo-example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// workflow.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;leadWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LeadInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;enrichment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enrichLeadActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;summarizeLeadActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enrichment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertCrmActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifySlackActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&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;Then attach retry policy where failure actually happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proxyActivities&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./activities&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;startToCloseTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2 minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;maximumAttempts&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole point.&lt;/p&gt;

&lt;p&gt;Retry the unstable edge, not the entire process.&lt;/p&gt;

&lt;h2&gt;
  
  
  n8n: manual, but still worth doing
&lt;/h2&gt;

&lt;p&gt;n8n is great for orchestration.&lt;/p&gt;

&lt;p&gt;But if you want resumability, you have to design it intentionally.&lt;/p&gt;

&lt;p&gt;Useful pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;execution.retryOf&lt;/code&gt; to detect retries&lt;/li&gt;
&lt;li&gt;Error Trigger for remediation flows&lt;/li&gt;
&lt;li&gt;external state in Postgres, Redis, Airtable, or another store keyed by execution ID&lt;/li&gt;
&lt;li&gt;branching logic to skip completed steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical pattern in n8n is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create an &lt;code&gt;execution_id&lt;/code&gt; at the start&lt;/li&gt;
&lt;li&gt;save progress after each major step&lt;/li&gt;
&lt;li&gt;on retry, load prior state&lt;/li&gt;
&lt;li&gt;skip steps that already completed&lt;/li&gt;
&lt;li&gt;only rerun the failed node or subflow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives you something much closer to continuation instead of "start over and hope."&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick comparison: restart vs resume
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What happens after step 8 fails?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full workflow retry&lt;/td&gt;
&lt;td&gt;Steps 1-7 run again, model calls repeat, external writes may duplicate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step-level retry + checkpoints&lt;/td&gt;
&lt;td&gt;Only step 8 retries, prior outputs are reused, external writes stay protected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The cost problem is not subtle
&lt;/h2&gt;

&lt;p&gt;This part matters a lot if you run agents all day.&lt;/p&gt;

&lt;p&gt;Every full replay multiplies model calls.&lt;/p&gt;

&lt;p&gt;If your workflow has six LLM steps and one API timeout near the end, a naive retry can turn one failure into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;six extra inference calls&lt;/li&gt;
&lt;li&gt;repeated tool work&lt;/li&gt;
&lt;li&gt;more queue time&lt;/li&gt;
&lt;li&gt;more logs to untangle&lt;/li&gt;
&lt;li&gt;more chances to duplicate side effects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under per-token pricing, this gets expensive fast.&lt;/p&gt;

&lt;p&gt;And even if you are on flat-rate AI compute, wasted retries still hurt throughput.&lt;/p&gt;

&lt;p&gt;You are burning capacity on work that already succeeded.&lt;/p&gt;

&lt;p&gt;That is one reason I care about this pattern so much.&lt;/p&gt;

&lt;p&gt;If you are running agents in n8n, Make, Zapier, OpenClaw, or custom frameworks, step-level retries are one of the fastest ways to make the system both cheaper and less annoying.&lt;/p&gt;

&lt;p&gt;And if your stack still points at the OpenAI API with usage anxiety hanging over every replay, it is worth looking at a drop-in alternative like Standard Compute.&lt;/p&gt;

&lt;p&gt;Standard Compute gives you unlimited AI compute for a flat monthly price and works with existing OpenAI-compatible SDKs and HTTP clients. So when you fix your retry design, you are not also stuck babysitting per-token costs every time an automation gets noisy.&lt;/p&gt;

&lt;p&gt;That does not replace durable execution.&lt;/p&gt;

&lt;p&gt;It just means your cost model stops fighting your architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I would use again from day one
&lt;/h2&gt;

&lt;p&gt;If I were rebuilding that workflow today, I would do this immediately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Assign a stable execution ID at the start.&lt;/li&gt;
&lt;li&gt;Persist state after every expensive or meaningful step.&lt;/li&gt;
&lt;li&gt;Store outputs in a structured state object, not scattered logs.&lt;/li&gt;
&lt;li&gt;Put idempotency keys on every external write.&lt;/li&gt;
&lt;li&gt;Retry only failure-prone steps like LLM calls, HTTP requests, and queue operations.&lt;/li&gt;
&lt;li&gt;Resume from the last checkpoint instead of replaying the workflow.&lt;/li&gt;
&lt;li&gt;Send true failures to a human or remediation flow instead of blindly looping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a minimal checklist for production agents, use this:&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="c"&gt;# production sanity checklist&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; stable execution &lt;span class="nb"&gt;id&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; persisted checkpoint after each major step
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; idempotency key on every external write
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; step-level retry policy
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; resume path tested
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; duplicate-write protection tested
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; human escalation path &lt;span class="k"&gt;for &lt;/span&gt;hard failures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  My actual takeaway
&lt;/h2&gt;

&lt;p&gt;My agent did not need a more inspirational prompt.&lt;/p&gt;

&lt;p&gt;It needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;memory&lt;/li&gt;
&lt;li&gt;boundaries&lt;/li&gt;
&lt;li&gt;permission to continue where it left off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once I stopped retrying the whole thing, the self-healing part was embarrassingly simple.&lt;/p&gt;

&lt;p&gt;If your agent keeps "recovering" by replaying everything, it is not self-healing.&lt;/p&gt;

&lt;p&gt;It is just forgetting more aggressively.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>n8n</category>
      <category>devops</category>
    </item>
    <item>
      <title>My refund agent looked smart until it lied about Shopify, Stripe, and Zendesk 3 different ways</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:16:12 +0000</pubDate>
      <link>https://dev.to/lars_winstand/my-refund-agent-looked-smart-until-it-lied-about-shopify-stripe-and-zendesk-3-different-ways-42e2</link>
      <guid>https://dev.to/lars_winstand/my-refund-agent-looked-smart-until-it-lied-about-shopify-stripe-and-zendesk-3-different-ways-42e2</guid>
      <description>&lt;p&gt;I thought I had a prompt problem.&lt;/p&gt;

&lt;p&gt;I didn’t.&lt;/p&gt;

&lt;p&gt;I had a state problem.&lt;/p&gt;

&lt;p&gt;My support agent could read a Zendesk ticket, find the Shopify order, issue a Stripe refund, update the ticket, and reply to the customer. In staging, it looked great. In production, it started doing something much worse than "being wrong."&lt;/p&gt;

&lt;p&gt;It was confidently telling customers their refund was complete when the workflow had only fired an API call and hoped for the best.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;Because once an LLM can call real systems like Shopify, Stripe, and Zendesk, the failure mode changes. The model doesn’t need to hallucinate a tool name to hurt you. It just needs to overstate what happened after a valid tool call.&lt;/p&gt;

&lt;p&gt;That’s the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bug: valid tool calls, invalid conclusions
&lt;/h2&gt;

&lt;p&gt;A lot of teams hit an "agent failed a task" moment and immediately do one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tighten the prompt&lt;/li&gt;
&lt;li&gt;add more warnings&lt;/li&gt;
&lt;li&gt;enforce stricter JSON schemas&lt;/li&gt;
&lt;li&gt;switch models&lt;/li&gt;
&lt;li&gt;add more examples&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sometimes that helps.&lt;/p&gt;

&lt;p&gt;But if your agent can already call the right API with the right arguments, prompt work stops being the main lever.&lt;/p&gt;

&lt;p&gt;OpenAI’s Structured Outputs are a real improvement here. Their published evals showed &lt;code&gt;gpt-4o-2024-08-06&lt;/code&gt; hitting 100% schema adherence on complex JSON schemas, versus less than 40% for &lt;code&gt;gpt-4-0613&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That’s great.&lt;/p&gt;

&lt;p&gt;But schema adherence is not operational truth.&lt;/p&gt;

&lt;p&gt;You can have perfect JSON and still ship a support workflow that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reports success before money moved&lt;/li&gt;
&lt;li&gt;retries without preserving certainty&lt;/li&gt;
&lt;li&gt;drifts out of sync with async systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s what bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Shopify: a Refund object is not proof that money moved
&lt;/h2&gt;

&lt;p&gt;This was my first clue.&lt;/p&gt;

&lt;p&gt;The agent called Shopify &lt;code&gt;refundCreate&lt;/code&gt;, got back a &lt;code&gt;Refund&lt;/code&gt; object, and then told the customer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your refund has been processed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Looks reasonable, right?&lt;/p&gt;

&lt;p&gt;Not really.&lt;/p&gt;

&lt;p&gt;Shopify’s docs are explicit: the existence of a &lt;code&gt;Refund&lt;/code&gt; object does not guarantee the financial transaction completed. The actual outcome lives on the related &lt;code&gt;OrderTransaction&lt;/code&gt; objects, which can be &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, or &lt;code&gt;failure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So this pattern is broken:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;call &lt;code&gt;refundCreate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;see &lt;code&gt;refund.id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;tell the customer it’s done&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s not confirmation. That’s optimism with JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mutation is not the problem
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RefundOrder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;@idempotent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund-order-123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;refundCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/Order/123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer requested partial refund"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;refund&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;userErrors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;The bug happens after the mutation.&lt;/p&gt;

&lt;p&gt;You need a second step that checks the transaction state before customer-facing messaging.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safer pattern
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refundInShopify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;shopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refundCreate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userErrors&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userErrors&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;shopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrderTransactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refundTxn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refund&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;refundTxn&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No refund transaction found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refundTxn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failure&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;default&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;That one extra verification step changes the customer message from guesswork into something defensible.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Stripe: timeouts turn "retry" into a reliability bug
&lt;/h2&gt;

&lt;p&gt;Stripe is where this gets dangerous.&lt;/p&gt;

&lt;p&gt;Imagine this flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;your agent sends &lt;code&gt;POST /v1/refunds&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;network hiccup&lt;/li&gt;
&lt;li&gt;worker times out&lt;/li&gt;
&lt;li&gt;response never gets persisted&lt;/li&gt;
&lt;li&gt;model decides to "try again"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now you have uncertainty.&lt;/p&gt;

&lt;p&gt;Did Stripe create the refund?&lt;br&gt;
Did it fail?&lt;br&gt;
Did it succeed and you just lost the response?&lt;/p&gt;

&lt;p&gt;This is exactly why Stripe idempotency keys exist.&lt;/p&gt;

&lt;p&gt;And too many agent workflows still treat idempotency as optional.&lt;/p&gt;

&lt;p&gt;It’s not optional.&lt;/p&gt;

&lt;p&gt;It’s the thing that lets you retry without destroying your chain of evidence.&lt;/p&gt;
&lt;h3&gt;
  
  
  Correct Stripe call
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/refunds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"sk_test_...:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: 8b5b9f2e-6f8d-4f3d-a6d8-2f0f4d7f9c21"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ch_123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Stripe stores the first result for a given idempotency key and returns the same status code and body on retries, including &lt;code&gt;500&lt;/code&gt; errors. If you reuse the same key with different parameters, Stripe rejects it.&lt;/p&gt;

&lt;p&gt;That means your workflow needs to persist the key before the call, not after.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bad retry logic
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;badRefundRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargeId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// terrible: new request identity, no certainty&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargeId&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;h3&gt;
  
  
  Better retry logic
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createRefundWithRecovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existingKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existingKey&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refundAttempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;started&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargeId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refundAttempts&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="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;refundId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rawResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refundAttempts&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="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;That &lt;code&gt;unknown&lt;/code&gt; state matters.&lt;/p&gt;

&lt;p&gt;A lot of teams try to avoid it because it feels messy. But "unknown" is honest. Telling the model to guess is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  3) Zendesk: async jobs and rate limits expose toy workflows fast
&lt;/h2&gt;

&lt;p&gt;Zendesk is where polished demos usually fall apart.&lt;/p&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;
&lt;h3&gt;
  
  
  Rate limits are not a suggestion
&lt;/h3&gt;

&lt;p&gt;Zendesk Support and Help Center API limits vary by plan. Responses can include headers like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Rate-Limit: 700
X-Rate-Limit-Remaining: 699
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you hit the limit, you can get &lt;code&gt;429 Too Many Requests&lt;/code&gt; plus &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A model that keeps hammering the API because it wants to be helpful is not helping.&lt;/p&gt;

&lt;p&gt;It’s just an expensive loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some actions are jobs, not immediate completion
&lt;/h3&gt;

&lt;p&gt;Bulk ticket updates are a classic example.&lt;/p&gt;

&lt;p&gt;The workflow sends the update, gets an acknowledgment, and assumes the tickets changed.&lt;/p&gt;

&lt;p&gt;But Zendesk job statuses can sit in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;queued&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;working&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your agent refunds 40 orders and bulk-updates 40 tickets, you cannot assume the ticket side finished just because the first call returned &lt;code&gt;200&lt;/code&gt; or &lt;code&gt;202&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You need to poll the job URL and reconcile failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimal polling example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForZendeskJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobStatusUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;zendesk&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="nx"&gt;jobStatusUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Zendesk job failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;working&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unknown Zendesk job state: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Zendesk job did not reach terminal state in time&lt;/span&gt;&lt;span class="dl"&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;This is not glamorous engineering.&lt;/p&gt;

&lt;p&gt;It is, however, the difference between a support agent that sounds polished and one that actually behaves reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt fixes vs orchestration fixes
&lt;/h2&gt;

&lt;p&gt;This is the distinction I wish I had made earlier.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it actually solves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Better prompts and tighter tool descriptions&lt;/td&gt;
&lt;td&gt;Reduces bad reasoning and malformed calls before execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured Outputs in GPT-4o or GPT-5&lt;/td&gt;
&lt;td&gt;Improves schema adherence and valid arguments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency keys in Stripe and Shopify patterns&lt;/td&gt;
&lt;td&gt;Prevents uncertainty and duplicate side effects during retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling Shopify transactions and Zendesk jobs&lt;/td&gt;
&lt;td&gt;Confirms real-world completion instead of assuming it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable state in LangGraph, n8n, or Make&lt;/td&gt;
&lt;td&gt;Lets you recover after timeouts, 429s, crashes, or partial completion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is why I think a lot of "LLM tool use reliability" discussions are framed too narrowly.&lt;/p&gt;

&lt;p&gt;Once you touch money, tickets, orders, or customer records, you’re not debugging a chatbot anymore.&lt;/p&gt;

&lt;p&gt;You’re doing distributed systems work.&lt;/p&gt;

&lt;p&gt;The LLM is just one component in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I trust now
&lt;/h2&gt;

&lt;p&gt;If I had to compress this into one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never let the model communicate success from the first side-effecting response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here’s the cheat sheet.&lt;/p&gt;

&lt;p&gt;| Check | Shopify refund flow | Stripe refund flow |&lt;br&gt;
|----------|----------|&lt;br&gt;
| Does the initial object guarantee money movement? | No. A &lt;code&gt;Refund&lt;/code&gt; object alone is not proof the refund settled | Usually the refund response is the main record, but retries must preserve idempotency to keep certainty |&lt;br&gt;
| Do you need post-call verification? | Yes. Check associated &lt;code&gt;OrderTransaction&lt;/code&gt; status like &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, or &lt;code&gt;failure&lt;/code&gt; | Yes, especially after timeouts or network failures; verify using the same idempotent request history or follow-up retrieval |&lt;br&gt;
| What should retries rely on? | Stored workflow state and explicit verification logic | The same idempotency key with the same parameters |&lt;/p&gt;

&lt;p&gt;That last row is where a lot of agent workflows quietly fail.&lt;/p&gt;

&lt;p&gt;Not because Shopify or Stripe are flaky.&lt;/p&gt;

&lt;p&gt;Because the workflow was stateless during retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need LangGraph, or are n8n / Make enough?
&lt;/h2&gt;

&lt;p&gt;My opinion: not every support automation needs a full agent runtime.&lt;/p&gt;

&lt;p&gt;If the flow is mostly deterministic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;look up order&lt;/li&gt;
&lt;li&gt;issue refund&lt;/li&gt;
&lt;li&gt;update ticket&lt;/li&gt;
&lt;li&gt;send reply&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;code&gt;n8n&lt;/code&gt;, &lt;code&gt;Make&lt;/code&gt;, or even &lt;code&gt;Zapier&lt;/code&gt; with explicit branches can be safer than an autonomous loop.&lt;/p&gt;

&lt;p&gt;You can hard-code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;wait steps&lt;/li&gt;
&lt;li&gt;rate limit handling&lt;/li&gt;
&lt;li&gt;persisted IDs&lt;/li&gt;
&lt;li&gt;human escalation branches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the workflow is more open-ended and needs resumability across long windows, partial failure recovery, or branching investigation, then &lt;code&gt;LangGraph&lt;/code&gt; starts making more sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tiny LangGraph skeleton
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MessagesState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mock_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MessagesState&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;messages&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;ai&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;hello world&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;

&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessagesState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock_llm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mock_llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mock_llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;hi!&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;That example is trivial.&lt;/p&gt;

&lt;p&gt;The important part is the mindset shift: state transitions, checkpoints, resumability, deterministic recovery.&lt;/p&gt;

&lt;p&gt;Not "maybe GPT-5 or Claude Opus 4.6 will be more careful next time."&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that finally stopped the lying
&lt;/h2&gt;

&lt;p&gt;What actually fixed this for me was boring.&lt;/p&gt;

&lt;p&gt;I split the workflow into phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;prepare the action with validated inputs&lt;/li&gt;
&lt;li&gt;execute with an idempotency key or equivalent request identity&lt;/li&gt;
&lt;li&gt;persist external IDs and raw responses immediately&lt;/li&gt;
&lt;li&gt;verify downstream state in Shopify, Stripe, or Zendesk&lt;/li&gt;
&lt;li&gt;communicate only from verified state&lt;/li&gt;
&lt;li&gt;escalate ambiguous or non-terminal cases to a human&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That did more for reliability than all the prompt tuning combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you’re running AI agents in production, cost pressure makes this worse
&lt;/h2&gt;

&lt;p&gt;There’s one more thing people don’t talk about enough: per-token pricing pushes teams toward bad reliability decisions.&lt;/p&gt;

&lt;p&gt;When every retry, poll, verification step, and recovery branch feels like metered spend, people start trimming the boring parts.&lt;/p&gt;

&lt;p&gt;They skip verification.&lt;br&gt;
They shorten retries.&lt;br&gt;
They avoid durable state.&lt;br&gt;
They let the model improvise because it looks cheaper in the moment.&lt;/p&gt;

&lt;p&gt;That is exactly backwards.&lt;/p&gt;

&lt;p&gt;Production-grade agent workflows need room for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;polling&lt;/li&gt;
&lt;li&gt;recovery steps&lt;/li&gt;
&lt;li&gt;reconciliation passes&lt;/li&gt;
&lt;li&gt;long-running automations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s a big reason tools like Standard Compute are interesting to teams building support agents, n8n flows, Make scenarios, and custom automations. If you’re using the OpenAI-compatible API shape but want predictable flat-cost compute instead of per-token anxiety, it changes how aggressively you can design for reliability.&lt;/p&gt;

&lt;p&gt;You stop asking, "Can we afford another verification pass?"&lt;/p&gt;

&lt;p&gt;You start asking the better question:&lt;/p&gt;

&lt;p&gt;"What would make this workflow stop lying to customers?"&lt;/p&gt;

&lt;p&gt;That’s the right optimization target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;If your refund agent sounds smart but sometimes lies, don’t assume the fix is a better prompt.&lt;/p&gt;

&lt;p&gt;Check whether your workflow is doing any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;confirming success from an initial API response&lt;/li&gt;
&lt;li&gt;retrying without idempotency&lt;/li&gt;
&lt;li&gt;failing to persist request identity&lt;/li&gt;
&lt;li&gt;skipping async job polling&lt;/li&gt;
&lt;li&gt;sending customer-facing messages before reconciliation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s usually where the real bug lives.&lt;/p&gt;

&lt;p&gt;The painful lesson for me was simple:&lt;/p&gt;

&lt;p&gt;Side effects are not chat turns.&lt;/p&gt;

&lt;p&gt;They’re distributed transactions wearing a chatbot costume.&lt;/p&gt;

&lt;p&gt;And once you treat them that way, your agents get a lot less charming and a lot more trustworthy.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>devops</category>
      <category>api</category>
    </item>
    <item>
      <title>We rebuilt 47-node n8n flows until we admitted script-first AI workflows are just better</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sun, 20 Sep 2026 06:08:48 +0000</pubDate>
      <link>https://dev.to/lars_winstand/we-rebuilt-47-node-n8n-flows-until-we-admitted-script-first-ai-workflows-are-just-better-1b9g</link>
      <guid>https://dev.to/lars_winstand/we-rebuilt-47-node-n8n-flows-until-we-admitted-script-first-ai-workflows-are-just-better-1b9g</guid>
      <description>&lt;h1&gt;
  
  
  We rebuilt 47-node n8n flows until we admitted script-first AI workflows are just better
&lt;/h1&gt;

&lt;p&gt;At 2:07 a.m., an n8n run failed because GPT-5.4 returned one ugly JSON blob that didn’t match what the next node expected.&lt;/p&gt;

&lt;p&gt;Not a big outage. Not a total failure.&lt;/p&gt;

&lt;p&gt;Just one malformed field buried inside a 47-node flow with branches, retries, fallback prompts, webhook handling, and a couple of emergency fixes living in an n8n Code node.&lt;/p&gt;

&lt;p&gt;So we did the usual thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;opened execution history&lt;/li&gt;
&lt;li&gt;clicked through branches one by one&lt;/li&gt;
&lt;li&gt;checked whether the parser broke&lt;/li&gt;
&lt;li&gt;checked whether retry logic fired&lt;/li&gt;
&lt;li&gt;checked whether Claude Opus 4.6 behaved differently on the previous run&lt;/li&gt;
&lt;li&gt;tried to remember why one branch existed at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An hour later, we were still debugging a flow that was supposed to save time.&lt;/p&gt;

&lt;p&gt;That was the moment we stopped pretending visual AI workflows scale cleanly.&lt;/p&gt;

&lt;p&gt;My opinion now is pretty simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use n8n, Make, and Zapier for orchestration. Put the AI decision layer in code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your workflow has real branching, retries, schema validation, provider switching, and tests, script-first wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't n8n
&lt;/h2&gt;

&lt;p&gt;I like n8n.&lt;/p&gt;

&lt;p&gt;It's great for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;webhooks&lt;/li&gt;
&lt;li&gt;cron jobs&lt;/li&gt;
&lt;li&gt;Slack notifications&lt;/li&gt;
&lt;li&gt;database writes&lt;/li&gt;
&lt;li&gt;approvals&lt;/li&gt;
&lt;li&gt;app-to-app glue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same story with Make and Zapier. They are useful because they remove a lot of boring integration work.&lt;/p&gt;

&lt;p&gt;The problem starts when your workflow tool becomes your application runtime.&lt;/p&gt;

&lt;p&gt;That works fine for deterministic automations.&lt;/p&gt;

&lt;p&gt;It gets ugly fast when the core logic depends on LLMs.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI workflows break in ways normal automations don't
&lt;/h2&gt;

&lt;p&gt;A normal automation is usually predictable enough that a visual graph stays readable.&lt;/p&gt;

&lt;p&gt;A lead comes in. You enrich it. You write to a CRM. You send a message.&lt;/p&gt;

&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;AI automations are different.&lt;/p&gt;

&lt;p&gt;Now you need to handle things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPT-5.4 returning valid JSON 95 times and weird prose on the 96th&lt;/li&gt;
&lt;li&gt;Claude Opus 4.6 extracting the right answer but missing your exact schema&lt;/li&gt;
&lt;li&gt;Grok 4.20 being good enough for one classification step but not another&lt;/li&gt;
&lt;li&gt;retries for rate limits, but not retries for bad outputs&lt;/li&gt;
&lt;li&gt;fallback prompts only after validation fails&lt;/li&gt;
&lt;li&gt;parsing rules shared across multiple automations&lt;/li&gt;
&lt;li&gt;provider swaps without rewriting the whole flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; model all of that with nodes and branches.&lt;/p&gt;

&lt;p&gt;We did.&lt;/p&gt;

&lt;p&gt;That's also how you end up with a workflow nobody wants to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden tax is confidence
&lt;/h2&gt;

&lt;p&gt;The biggest problem wasn't breakage.&lt;/p&gt;

&lt;p&gt;It was confidence.&lt;/p&gt;

&lt;p&gt;Once your business logic lives across dozens of workflow nodes, every change feels dangerous.&lt;/p&gt;

&lt;p&gt;Rename one field? Maybe three branches break.&lt;/p&gt;

&lt;p&gt;Swap one model? Maybe parsing changes in five places.&lt;/p&gt;

&lt;p&gt;Add one retry path? Maybe a fallback branch now loops in a way nobody expected.&lt;/p&gt;

&lt;p&gt;This is where Make routers and Zapier Paths hit the same wall too. They look manageable at first. Then AI edge cases pile up and the whole thing turns into a visual choose-your-own-adventure with no real test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried before moving logic into code
&lt;/h2&gt;

&lt;p&gt;We tried to be disciplined inside the workflow builder.&lt;/p&gt;

&lt;p&gt;We:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cleaned up node names&lt;/li&gt;
&lt;li&gt;added comments&lt;/li&gt;
&lt;li&gt;split branches more clearly&lt;/li&gt;
&lt;li&gt;pushed parsing into an n8n Code node&lt;/li&gt;
&lt;li&gt;tightened prompts&lt;/li&gt;
&lt;li&gt;added validation steps&lt;/li&gt;
&lt;li&gt;added retries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That helped for about five minutes.&lt;/p&gt;

&lt;p&gt;The real problem was architectural.&lt;/p&gt;

&lt;p&gt;We were using orchestration software as an app runtime.&lt;/p&gt;

&lt;p&gt;That meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;versioning was bad&lt;/li&gt;
&lt;li&gt;reviewing changes was bad&lt;/li&gt;
&lt;li&gt;reusing logic was bad&lt;/li&gt;
&lt;li&gt;testing was mostly "run it and see"&lt;/li&gt;
&lt;li&gt;rollback was clumsy&lt;/li&gt;
&lt;li&gt;provider comparisons were annoying&lt;/li&gt;
&lt;li&gt;debugging meant clicking through execution trails instead of reading logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The breaking point came when we wanted to swap providers without rewriting the automation.&lt;/p&gt;

&lt;p&gt;That should be a config change.&lt;/p&gt;

&lt;p&gt;Inside a node maze, it turns into surgery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should stay in n8n vs what should move to code
&lt;/h2&gt;

&lt;p&gt;Here's the split I wish we'd used earlier.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keep in n8n / Make / Zapier&lt;/th&gt;
&lt;th&gt;Move to code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Triggers&lt;/td&gt;
&lt;td&gt;Prompt construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook entry points&lt;/td&gt;
&lt;td&gt;JSON schema validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schedules&lt;/td&gt;
&lt;td&gt;Retry policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human approvals&lt;/td&gt;
&lt;td&gt;Fallback logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App-to-app handoffs&lt;/td&gt;
&lt;td&gt;Provider abstraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack / email / CRM actions&lt;/td&gt;
&lt;td&gt;Shared parsing rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database writes&lt;/td&gt;
&lt;td&gt;Testable business logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;workflow tools should orchestrate&lt;/li&gt;
&lt;li&gt;code should decide&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What script-first actually looks like
&lt;/h2&gt;

&lt;p&gt;You don't need to throw away n8n.&lt;/p&gt;

&lt;p&gt;Just stop asking it to own the hardest part.&lt;/p&gt;

&lt;p&gt;A much better pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;n8n receives the trigger&lt;/li&gt;
&lt;li&gt;n8n sends data to a script or internal service&lt;/li&gt;
&lt;li&gt;the service handles prompts, validation, retries, and model routing&lt;/li&gt;
&lt;li&gt;the service returns a stable payload&lt;/li&gt;
&lt;li&gt;n8n handles downstream actions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That service can be tiny. It doesn't need to be a giant platform.&lt;/p&gt;

&lt;p&gt;It just needs to put the AI logic somewhere that Git, tests, logs, and refactors actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical example
&lt;/h2&gt;

&lt;p&gt;Let's say you have an n8n workflow that triages inbound support tickets.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fragile version
&lt;/h3&gt;

&lt;p&gt;Inside n8n:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;call GPT-5.4&lt;/li&gt;
&lt;li&gt;parse JSON&lt;/li&gt;
&lt;li&gt;branch on category&lt;/li&gt;
&lt;li&gt;retry on failure&lt;/li&gt;
&lt;li&gt;call Claude Opus 4.6 if parsing fails&lt;/li&gt;
&lt;li&gt;reformat output&lt;/li&gt;
&lt;li&gt;branch again on urgency&lt;/li&gt;
&lt;li&gt;send to Slack or Zendesk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This works until it doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  The better version
&lt;/h3&gt;

&lt;p&gt;n8n just calls your service:&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;-X&lt;/span&gt; POST http://internal-ai-service/triage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "ticket_id": "123",
    "subject": "Customer cannot log in",
    "body": "I reset my password twice and still get an error"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your service handles the messy part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TicketSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;urgency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;needs_human&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STANDARD_COMPUTE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.standardcompute.com/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;triageTicket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai/gpt-5.4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&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;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classify support tickets and return strict JSON only.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Subject: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nBody: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json_object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TicketSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parsed&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 your workflow gets one stable response:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User cannot log in after password reset attempts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"needs_human"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&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;That is way easier to reason about than 8 branches and 3 parser nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is better for developers
&lt;/h2&gt;

&lt;p&gt;Once the AI layer is code-first, you get normal engineering tools back.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Real version control
&lt;/h3&gt;

&lt;p&gt;You can review prompt changes in Git.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see exactly what changed in validation logic, retry policy, or provider selection.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tests
&lt;/h3&gt;

&lt;p&gt;You can write tests for the parts that matter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;triageTicket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;classifies login issues as account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;triageTicket&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Locked out&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reset password twice, still can't log in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;"&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;No, LLM tests won't be perfectly deterministic.&lt;/p&gt;

&lt;p&gt;They're still better than clicking "Execute Workflow" and hoping.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Shared logic
&lt;/h3&gt;

&lt;p&gt;One parser. One schema. One retry policy.&lt;/p&gt;

&lt;p&gt;Used everywhere.&lt;/p&gt;

&lt;p&gt;Not copied across five automations.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Provider abstraction
&lt;/h3&gt;

&lt;p&gt;If GPT-5.4 is best for one task but Claude Opus 4.6 is better for another, that should be hidden behind one internal interface.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;gpt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai/gpt-5.4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;claude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anthropic/claude-opus-4.6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;grok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xai/grok-4.20&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;modelMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&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;That is a small change in code.&lt;/p&gt;

&lt;p&gt;Inside a giant workflow, it often becomes a full rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters even more for 24/7 agents
&lt;/h2&gt;

&lt;p&gt;If a workflow runs twice a day, you can tolerate some mess.&lt;/p&gt;

&lt;p&gt;If an agent runs all day processing tickets, leads, documents, or support threads, every weak spot turns into operational drag.&lt;/p&gt;

&lt;p&gt;A flaky branch isn't a minor annoyance anymore.&lt;/p&gt;

&lt;p&gt;It's a permanent tax.&lt;/p&gt;

&lt;p&gt;This is also where cost and throughput start to matter.&lt;/p&gt;

&lt;p&gt;Teams running AI agents in n8n, Make, Zapier, OpenClaw, or custom workflows usually do not want to babysit token spend while background jobs keep firing.&lt;/p&gt;

&lt;p&gt;They want the automation to run.&lt;/p&gt;

&lt;p&gt;That is one reason Standard Compute is interesting in this setup.&lt;/p&gt;

&lt;p&gt;It gives you an OpenAI-compatible endpoint, so you can keep using the OpenAI SDK or any compatible HTTP client, while routing across GPT-5.4, Claude Opus 4.6, and Grok 4.20 behind the scenes.&lt;/p&gt;

&lt;p&gt;More importantly, it's flat-rate instead of per-token.&lt;/p&gt;

&lt;p&gt;That matters a lot for always-on automations, because architecture decisions stop being distorted by token anxiety.&lt;/p&gt;

&lt;p&gt;You can keep the script-first pattern, keep your orchestration layer the same, and avoid rebuilding workflows every time model pricing or provider quality shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple architecture that works
&lt;/h2&gt;

&lt;p&gt;If I were starting over, I'd use this split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n8n / Make / Zapier
  -&amp;gt; trigger, schedule, webhook, notifications
  -&amp;gt; call internal AI service

Internal AI service
  -&amp;gt; prompt construction
  -&amp;gt; model routing
  -&amp;gt; schema validation
  -&amp;gt; retries
  -&amp;gt; logging
  -&amp;gt; tests

Downstream systems
  -&amp;gt; Slack
  -&amp;gt; CRM
  -&amp;gt; database
  -&amp;gt; email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring architecture is underrated.&lt;/p&gt;

&lt;p&gt;This one is boring in the best possible way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actionable rule of thumb
&lt;/h2&gt;

&lt;p&gt;If your workflow has any of these, move the AI logic into code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more than one model provider&lt;/li&gt;
&lt;li&gt;strict JSON requirements&lt;/li&gt;
&lt;li&gt;retries based on failure type&lt;/li&gt;
&lt;li&gt;shared prompt logic&lt;/li&gt;
&lt;li&gt;reusable validation&lt;/li&gt;
&lt;li&gt;complex branching&lt;/li&gt;
&lt;li&gt;production consequences when it breaks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the workflow builder for orchestration.&lt;/p&gt;

&lt;p&gt;Keep the brain in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final take
&lt;/h2&gt;

&lt;p&gt;The issue was never that n8n was bad.&lt;/p&gt;

&lt;p&gt;The issue was that we kept asking n8n to do a job better handled by code.&lt;/p&gt;

&lt;p&gt;Use n8n for orchestration.&lt;br&gt;
Use Make for orchestration.&lt;br&gt;
Use Zapier for orchestration.&lt;/p&gt;

&lt;p&gt;But if your AI workflow has real logic, real retries, real validation, and real provider switching, put that part in a script or service.&lt;/p&gt;

&lt;p&gt;We learned that after rebuilding broken flows more times than I'd like to admit.&lt;/p&gt;

&lt;p&gt;I wouldn't go back.&lt;/p&gt;

&lt;p&gt;If you're already running AI automations this way, I'm curious where you draw the line between workflow builder and code.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>n8n</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>I tested VM vs Docker security for agent workers because “just use Docker” stopped feeling like an answer</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sat, 19 Sep 2026 22:09:07 +0000</pubDate>
      <link>https://dev.to/lars_winstand/i-tested-vm-vs-docker-security-for-agent-workers-because-just-use-docker-stopped-feeling-like-an-4ia5</link>
      <guid>https://dev.to/lars_winstand/i-tested-vm-vs-docker-security-for-agent-workers-because-just-use-docker-stopped-feeling-like-an-4ia5</guid>
      <description>&lt;p&gt;A few months ago, I caught myself saying something I’d heard a hundred times before:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Just run it in Docker.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the second it came out of my mouth, it sounded weak.&lt;/p&gt;

&lt;p&gt;Because we weren’t talking about a boring internal cron job.&lt;/p&gt;

&lt;p&gt;We were talking about agent workers that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;open browser sessions&lt;/li&gt;
&lt;li&gt;run generated code&lt;/li&gt;
&lt;li&gt;call external APIs&lt;/li&gt;
&lt;li&gt;ingest weird files and HTML&lt;/li&gt;
&lt;li&gt;sometimes touch customer data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s a very different threat model from “containerize the app and move on.”&lt;/p&gt;

&lt;p&gt;So I went back to the docs instead of repeating container folklore.&lt;/p&gt;

&lt;p&gt;The answer I landed on is not “Docker is unsafe” and it’s not “VMs everywhere.”&lt;/p&gt;

&lt;p&gt;It’s this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Docker shares the host Linux kernel. VMs and microVMs add a stronger isolation boundary. For trusted internal jobs, hardened rootless Docker is often enough. For browser agents, generated code, or multi-tenant data, I’d choose a VM or microVM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you’re building workers for n8n, OpenClaw, Playwright, custom GPT-5 workflows, Claude-powered automations, or anything that looks like an AI agent runtime, the distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sentence in Docker’s docs that changed the whole conversation
&lt;/h2&gt;

&lt;p&gt;Docker is actually pretty clear if you read the security docs.&lt;/p&gt;

&lt;p&gt;Containers are not tiny VMs.&lt;/p&gt;

&lt;p&gt;They rely on the host Linux kernel for isolation.&lt;/p&gt;

&lt;p&gt;VMs don’t. They add a hardware virtualization boundary between guest and host.&lt;/p&gt;

&lt;p&gt;That sounds obvious, but people still talk about Docker like it creates a hard wall by default. It doesn’t.&lt;/p&gt;

&lt;p&gt;Docker’s security model explicitly points you toward four areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;kernel namespaces and cgroups&lt;/li&gt;
&lt;li&gt;the Docker daemon attack surface&lt;/li&gt;
&lt;li&gt;container configuration loopholes&lt;/li&gt;
&lt;li&gt;kernel hardening features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list tells you a lot.&lt;/p&gt;

&lt;p&gt;If your security argument is just “it’s in a container,” you’re skipping the part where Docker itself says configuration, daemon exposure, and kernel hardening all matter.&lt;/p&gt;

&lt;p&gt;That was my first real takeaway:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Containerization is a starting point, not an isolation guarantee.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When Docker is actually enough
&lt;/h2&gt;

&lt;p&gt;My opinion after re-reading all this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker is often fine for trusted, single-tenant internal workers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;internal automation triggered by your own team&lt;/li&gt;
&lt;li&gt;structured inputs&lt;/li&gt;
&lt;li&gt;calls to Notion, HubSpot, Salesforce, PostgreSQL&lt;/li&gt;
&lt;li&gt;no arbitrary code execution&lt;/li&gt;
&lt;li&gt;no hostile user content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not automatically require a VM.&lt;/p&gt;

&lt;p&gt;But there’s a huge difference between hardened Docker and lazy Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lazy version vs the version I’d actually trust
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The lazy version
&lt;/h3&gt;

&lt;p&gt;This is the version I see all the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rootful Docker&lt;/li&gt;
&lt;li&gt;default seccomp&lt;/li&gt;
&lt;li&gt;broad Linux capabilities&lt;/li&gt;
&lt;li&gt;writable filesystem&lt;/li&gt;
&lt;li&gt;wide-open egress&lt;/li&gt;
&lt;li&gt;loose volume mounts&lt;/li&gt;
&lt;li&gt;default networking&lt;/li&gt;
&lt;li&gt;no AppArmor tuning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That setup is common.&lt;/p&gt;

&lt;p&gt;It’s also the exact setup behind a lot of false confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The version I’d trust for lower-risk workers
&lt;/h3&gt;

&lt;p&gt;For a lower-risk internal worker, I’d want at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rootless Docker&lt;/li&gt;
&lt;li&gt;dropped Linux capabilities&lt;/li&gt;
&lt;li&gt;read-only filesystem where possible&lt;/li&gt;
&lt;li&gt;tight volume mounts&lt;/li&gt;
&lt;li&gt;outbound network restrictions&lt;/li&gt;
&lt;li&gt;AppArmor or another LSM policy&lt;/li&gt;
&lt;li&gt;a reviewed seccomp profile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That still does not give you VM-grade isolation.&lt;/p&gt;

&lt;p&gt;But it does materially reduce risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rootless Docker is the first upgrade I’d make
&lt;/h2&gt;

&lt;p&gt;If I inherit a sketchy worker box running Playwright, Python job runners, or n8n sidecars, the first thing I’d check is whether Docker is running rootless.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because in rootless mode, both the Docker daemon and containers run as a non-root user.&lt;/p&gt;

&lt;p&gt;That changes the blast radius in a meaningful way.&lt;/p&gt;

&lt;p&gt;You can check the subordinate UID/GID mappings like this:&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;grep&lt;/span&gt; ^&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;: /etc/subuid
&lt;span class="nb"&gt;grep&lt;/span&gt; ^&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;: /etc/subgid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker rootless mode expects subordinate ID ranges, typically at least 65536 IDs.&lt;/p&gt;

&lt;p&gt;Setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dockerd-rootless-setuptool.sh &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s not a marketing checkbox. It’s a real change to how privilege is handled.&lt;/p&gt;

&lt;p&gt;If your current answer to agent isolation is “we use Docker,” but you’re still running rootful with broad defaults, I would fix that before arguing about anything fancier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seccomp helps, but it doesn’t turn a container into a VM
&lt;/h2&gt;

&lt;p&gt;This is another place where people overstate things.&lt;/p&gt;

&lt;p&gt;Docker’s default seccomp profile is useful.&lt;/p&gt;

&lt;p&gt;It blocks a set of syscalls and reduces attack surface.&lt;/p&gt;

&lt;p&gt;That’s good.&lt;/p&gt;

&lt;p&gt;But it is not a magic sandbox.&lt;/p&gt;

&lt;p&gt;You can run with a custom seccomp profile like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; &lt;span class="nv"&gt;seccomp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/path/to/seccomp/profile.json &lt;span class="se"&gt;\&lt;/span&gt;
  hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if the workload is sensitive enough, you probably should.&lt;/p&gt;

&lt;p&gt;Same story for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dropping capabilities&lt;/li&gt;
&lt;li&gt;using &lt;code&gt;no-new-privileges&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;applying AppArmor&lt;/li&gt;
&lt;li&gt;locking down mounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; no-new-privileges:true &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256 &lt;span class="se"&gt;\&lt;/span&gt;
  my-worker:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s real hardening.&lt;/p&gt;

&lt;p&gt;But the core fact does not change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hardened container is still a shared-kernel model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That matters a lot once your workers start doing agent-like things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment I stop trusting plain containers
&lt;/h2&gt;

&lt;p&gt;This is where my opinion gets less diplomatic.&lt;/p&gt;

&lt;p&gt;If a worker does any of the following, I think VM or microVM should be the default:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;runs browser agents with persistent sessions&lt;/li&gt;
&lt;li&gt;executes generated code from an LLM&lt;/li&gt;
&lt;li&gt;handles customer data across tenants&lt;/li&gt;
&lt;li&gt;pulls untrusted files, repos, PDFs, or HTML&lt;/li&gt;
&lt;li&gt;needs a security story that survives audit or review&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because these workloads are messy.&lt;/p&gt;

&lt;p&gt;A browser agent is basically an automation engine pointed at untrusted content.&lt;/p&gt;

&lt;p&gt;A code-executing agent is literally running output you didn’t hand-write.&lt;/p&gt;

&lt;p&gt;A multi-tenant worker means one mistake can become a cross-customer incident.&lt;/p&gt;

&lt;p&gt;At that point, “we hardened Docker pretty well” starts sounding less like a strategy and more like a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gVisor, Kata Containers, and Firecracker exist
&lt;/h2&gt;

&lt;p&gt;The industry has already answered this problem several times.&lt;/p&gt;

&lt;p&gt;That’s why tools like these exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;gVisor&lt;/li&gt;
&lt;li&gt;Kata Containers&lt;/li&gt;
&lt;li&gt;Firecracker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all try to close the gap between container ergonomics and stronger isolation.&lt;/p&gt;

&lt;h3&gt;
  
  
  gVisor
&lt;/h3&gt;

&lt;p&gt;gVisor adds an extra defense layer by interposing a user-space kernel boundary.&lt;/p&gt;

&lt;p&gt;That’s useful when you still want container UX but don’t love the idea of untrusted code talking so directly to the host kernel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kata Containers
&lt;/h3&gt;

&lt;p&gt;Kata Containers uses lightweight VMs as a second isolation layer.&lt;/p&gt;

&lt;p&gt;That makes sense for teams that want something container-shaped operationally, but with stronger boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firecracker
&lt;/h3&gt;

&lt;p&gt;Firecracker is the one that made me take microVMs more seriously.&lt;/p&gt;

&lt;p&gt;It’s purpose-built for lightweight virtualization with minimal device surface and fast startup.&lt;/p&gt;

&lt;p&gt;And this is not lab-only infrastructure.&lt;/p&gt;

&lt;p&gt;Firecracker underpins AWS Lambda and AWS Fargate-style isolation patterns at enormous scale.&lt;/p&gt;

&lt;p&gt;That matters because it proves the model is practical, not theoretical.&lt;/p&gt;

&lt;h2&gt;
  
  
  My practical map of the options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What I’d use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rootless Docker&lt;/td&gt;
&lt;td&gt;Trusted or moderately risky internal workers where shared-kernel isolation is acceptable and the team will actually harden the runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gVisor&lt;/td&gt;
&lt;td&gt;Untrusted code where container UX still matters and you want stronger syscall isolation than plain Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kata Containers / Firecracker microVMs&lt;/td&gt;
&lt;td&gt;Browser agents, code execution, or sensitive multi-tenant workflows where stronger isolation is worth the operational complexity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That’s the trade space as I see it.&lt;/p&gt;

&lt;p&gt;Not ideological. Just matching the boundary to the workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you pay for stronger isolation
&lt;/h2&gt;

&lt;p&gt;Nothing about stronger isolation is free.&lt;/p&gt;

&lt;p&gt;You usually pay in some combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;startup latency&lt;/li&gt;
&lt;li&gt;throughput&lt;/li&gt;
&lt;li&gt;memory overhead&lt;/li&gt;
&lt;li&gt;compatibility quirks&lt;/li&gt;
&lt;li&gt;operational complexity&lt;/li&gt;
&lt;li&gt;debugging pain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s why I also don’t agree with “always use VMs.”&lt;/p&gt;

&lt;p&gt;If the workload is low-risk and trusted, the VM tax can be unnecessary.&lt;/p&gt;

&lt;p&gt;But if the workload is externally influenced, chaotic, or tenant-sensitive, that tax starts looking cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete way to think about it
&lt;/h2&gt;

&lt;p&gt;Here’s a rough decision tree I’d use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use hardened rootless Docker when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;the worker is single-tenant&lt;/li&gt;
&lt;li&gt;inputs are mostly trusted&lt;/li&gt;
&lt;li&gt;it does not execute arbitrary user-supplied code&lt;/li&gt;
&lt;li&gt;it does not process especially sensitive cross-customer data&lt;/li&gt;
&lt;li&gt;your team is willing to maintain hardening over time&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Use a VM or microVM when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;the worker runs browser automation or browser agents&lt;/li&gt;
&lt;li&gt;the worker executes generated code&lt;/li&gt;
&lt;li&gt;the worker processes untrusted files or web content&lt;/li&gt;
&lt;li&gt;the worker serves multiple customers or tenants&lt;/li&gt;
&lt;li&gt;you need a cleaner security story for compliance or review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the practical answer I wish people gave more often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example: a low-risk internal worker
&lt;/h2&gt;

&lt;p&gt;This is the kind of job I’m comfortable running in hardened Docker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;triggered by an internal webhook&lt;/li&gt;
&lt;li&gt;reads rows from PostgreSQL&lt;/li&gt;
&lt;li&gt;calls HubSpot and Slack&lt;/li&gt;
&lt;li&gt;writes results back&lt;/li&gt;
&lt;li&gt;no arbitrary code execution&lt;/li&gt;
&lt;li&gt;no browsing random websites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A run command might look something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; no-new-privileges:true &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;internal_only &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /app/tmp:/tmp:rw &lt;span class="se"&gt;\&lt;/span&gt;
  my-internal-worker:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I still wouldn’t call that bulletproof.&lt;/p&gt;

&lt;p&gt;I would call it proportionate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example: a worker I would move to a microVM immediately
&lt;/h2&gt;

&lt;p&gt;This one is different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepts prompts from users&lt;/li&gt;
&lt;li&gt;launches Playwright or Chrome&lt;/li&gt;
&lt;li&gt;logs into third-party sites&lt;/li&gt;
&lt;li&gt;downloads files&lt;/li&gt;
&lt;li&gt;runs generated Python or JavaScript&lt;/li&gt;
&lt;li&gt;stores session state&lt;/li&gt;
&lt;li&gt;serves multiple customers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is microVM territory for me.&lt;/p&gt;

&lt;p&gt;Not because containers are useless.&lt;/p&gt;

&lt;p&gt;Because the workload is too exposed, too dynamic, and too difficult to reason about with a shared kernel boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  This gets more relevant as AI agents get cheaper to run
&lt;/h2&gt;

&lt;p&gt;There’s also an operational angle here.&lt;/p&gt;

&lt;p&gt;As more teams run agents continuously, cost pressure pushes them toward denser infrastructure and more automation.&lt;/p&gt;

&lt;p&gt;That’s exactly where people start making dangerous simplifications like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;put more workers on the same host&lt;/li&gt;
&lt;li&gt;let them run longer&lt;/li&gt;
&lt;li&gt;allow browser sessions to persist&lt;/li&gt;
&lt;li&gt;execute more generated code&lt;/li&gt;
&lt;li&gt;stop watching per-run cost so closely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I actually think predictable compute pricing makes this easier to reason about operationally.&lt;/p&gt;

&lt;p&gt;If you’re running lots of agent calls through an OpenAI-compatible endpoint like Standard Compute, you can stop obsessing over token billing and spend more time deciding where the real risk boundary should be.&lt;/p&gt;

&lt;p&gt;That’s the part people miss.&lt;/p&gt;

&lt;p&gt;Cheap or flat-rate model access does not remove the need for isolation.&lt;/p&gt;

&lt;p&gt;It increases the odds that you’ll run more autonomous workloads, more often, with less human supervision.&lt;/p&gt;

&lt;p&gt;Which makes the isolation decision more important, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised me most
&lt;/h2&gt;

&lt;p&gt;I expected the answer to be mostly about technology.&lt;/p&gt;

&lt;p&gt;It wasn’t.&lt;/p&gt;

&lt;p&gt;It was about honesty.&lt;/p&gt;

&lt;p&gt;If someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We run it in Docker.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That can mean two very different things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;we put a risky workload in a shared-kernel environment and did serious hardening&lt;/li&gt;
&lt;li&gt;we put a risky workload in a shared-kernel environment and hoped the defaults were enough&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those are not the same sentence.&lt;/p&gt;

&lt;p&gt;For simple internal automations, Docker is often fine.&lt;/p&gt;

&lt;p&gt;For browser workers, code-executing agents, and customer-data workflows, I think VM or microVM is the safer default.&lt;/p&gt;

&lt;p&gt;Not because I’m anti-container.&lt;/p&gt;

&lt;p&gt;Because the docs are pretty clear once you stop treating “just use Docker” like a complete answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  My current rule of thumb
&lt;/h2&gt;

&lt;p&gt;If I trust the inputs and the blast radius is small, I’ll use hardened rootless Docker.&lt;/p&gt;

&lt;p&gt;If the worker browses the web, runs generated code, or touches multiple customers’ data, I want a VM or microVM boundary.&lt;/p&gt;

&lt;p&gt;That’s the line.&lt;/p&gt;

&lt;p&gt;And honestly, I think more agent infrastructure should start there.&lt;/p&gt;

&lt;p&gt;If you’re building AI workers on n8n, Make, Zapier, OpenClaw, or custom frameworks, this is also a good reminder to separate two decisions that people keep mixing together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how you call the model&lt;/li&gt;
&lt;li&gt;how you isolate the worker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the first problem, using an OpenAI-compatible endpoint with predictable pricing like Standard Compute can make agent workloads much easier to operate at scale.&lt;/p&gt;

&lt;p&gt;For the second problem, don’t let flat-rate inference lull you into weak runtime isolation.&lt;/p&gt;

&lt;p&gt;Different layer. Different risk. Same production system.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>We kept arguing about GPT vs Claude and missed the 2x long-context cost blowing up our agent runs</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:09:28 +0000</pubDate>
      <link>https://dev.to/lars_winstand/we-kept-arguing-about-gpt-vs-claude-and-missed-the-2x-long-context-cost-blowing-up-our-agent-runs-21cm</link>
      <guid>https://dev.to/lars_winstand/we-kept-arguing-about-gpt-vs-claude-and-missed-the-2x-long-context-cost-blowing-up-our-agent-runs-21cm</guid>
      <description>&lt;p&gt;A thing finally clicked for me while debugging an agent trace:&lt;/p&gt;

&lt;p&gt;we were spending way too much time arguing about model choice, and not enough time looking at the giant pile of context getting replayed on every step.&lt;/p&gt;

&lt;p&gt;Not user intent.&lt;br&gt;
Not fresh instructions.&lt;br&gt;
Just baggage.&lt;/p&gt;

&lt;p&gt;Old tool output. Debug blobs. Previous assistant replies. Summaries of summaries. Random thread history nobody trimmed.&lt;/p&gt;

&lt;p&gt;That stuff is easy to ignore because it accumulates one harmless-looking decision at a time.&lt;/p&gt;

&lt;p&gt;But in a lot of agent workflows, especially tool-heavy ones, that is where the bill gets weird.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hidden cost wasn't the model switch
&lt;/h2&gt;

&lt;p&gt;The contrarian take:&lt;/p&gt;

&lt;p&gt;For a lot of agent pipelines, repeated input context matters more than the headline per-token rate.&lt;/p&gt;

&lt;p&gt;OpenAI's current pricing makes this painfully obvious. Long-context input is priced at 2x short-context input across the lineup, and long-context output is 1.5x higher too.&lt;/p&gt;

&lt;p&gt;One concrete example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gpt-5.6-terra&lt;/code&gt; short-context input: &lt;code&gt;$1.00 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gpt-5.6-terra&lt;/code&gt; long-context input: &lt;code&gt;$2.00 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gpt-5.6-terra&lt;/code&gt; short-context output: &lt;code&gt;$6.00 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gpt-5.6-terra&lt;/code&gt; long-context output: &lt;code&gt;$9.00 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same model. Same app. Same team.&lt;/p&gt;

&lt;p&gt;The only difference is that one request is dragging around more history.&lt;/p&gt;

&lt;p&gt;That means a bloated thread can become a pricing decision all by itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this looks like in real agent code
&lt;/h2&gt;

&lt;p&gt;A lot of memory bugs don't look like bugs.&lt;/p&gt;

&lt;p&gt;They look like normal code:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.checkpoint.memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemorySaver&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&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;google_genai:gemini-3.6-flash&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;get_user_info&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;InMemorySaver&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;thread_config&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;configurable&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&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;1&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;Nothing scary there.&lt;/p&gt;

&lt;p&gt;But if that thread stays alive across multiple tool-heavy runs, the agent can keep pulling old state into new calls unless you explicitly trim or summarize it.&lt;/p&gt;

&lt;p&gt;And once tools are involved, each turn can add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;system instructions&lt;/li&gt;
&lt;li&gt;user messages&lt;/li&gt;
&lt;li&gt;assistant replies&lt;/li&gt;
&lt;li&gt;tool calls&lt;/li&gt;
&lt;li&gt;tool outputs&lt;/li&gt;
&lt;li&gt;intermediate state&lt;/li&gt;
&lt;li&gt;summaries&lt;/li&gt;
&lt;li&gt;more summaries later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple chatbot grows slowly.&lt;br&gt;
A workflow agent grows like a log file nobody rotates.&lt;/p&gt;
&lt;h2&gt;
  
  
  The biggest source of junk: tool output
&lt;/h2&gt;

&lt;p&gt;This is the one I see most often.&lt;/p&gt;

&lt;p&gt;A tool returns a huge JSON payload, and someone decides to pass the whole thing back into the next model call "just in case."&lt;/p&gt;

&lt;p&gt;Example:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cus_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tickets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;records&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recent_orders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;records&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"audit_log"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crm_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"very long string..."&lt;/span&gt;&lt;span class="w"&gt;
&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;Then the next step asks something tiny like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Should we escalate this support ticket?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That decision probably needs 10 facts, not 10,000 tokens.&lt;/p&gt;

&lt;p&gt;A better pattern is:&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;raw_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_customer_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;store_raw_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;summary&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;customer_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;raw_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&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;open_ticket_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickets&lt;/span&gt;&lt;span class="sh"&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;recent_order_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recent_orders&lt;/span&gt;&lt;span class="sh"&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;has_refund_last_30_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;has_recent_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority_signals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_priority_signals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;tool&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&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;Keep the raw data outside the prompt.&lt;br&gt;
Pass only the facts needed for the next decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  Observability data is not prompt data
&lt;/h2&gt;

&lt;p&gt;LangSmith traces are useful.&lt;br&gt;
OpenTelemetry spans are useful.&lt;br&gt;
Debug logs are useful.&lt;/p&gt;

&lt;p&gt;That does not mean they belong in your next model call.&lt;/p&gt;

&lt;p&gt;I've seen teams accidentally turn observability into recurring token spend by mirroring traces back into prompts so the model is "fully informed."&lt;/p&gt;

&lt;p&gt;That usually does two bad things at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;increases cost&lt;/li&gt;
&lt;li&gt;makes the model worse&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Too much stale context doesn't just cost more. It distracts the model.&lt;/p&gt;

&lt;p&gt;If your agent keeps seeing old tool results, outdated instructions, and irrelevant history, it starts anchoring on the wrong stuff.&lt;/p&gt;

&lt;p&gt;So this is not only a pricing problem.&lt;br&gt;
It's also an accuracy problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  Doesn't prompt caching solve this?
&lt;/h2&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;It helps, but it does not make sloppy context free.&lt;/p&gt;

&lt;p&gt;OpenAI still charges for cached input and cache writes.&lt;br&gt;
For &lt;code&gt;gpt-5.6-terra&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cached input short-context: &lt;code&gt;$0.10 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cached input long-context: &lt;code&gt;$0.20 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cache write short-context: &lt;code&gt;$1.25 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cache write long-context: &lt;code&gt;$2.50 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anthropic has the same basic story with different numbers. &lt;code&gt;Claude Opus 4.6&lt;/code&gt; pricing includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;input: &lt;code&gt;$5 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;output: &lt;code&gt;$25 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;5-minute cache write: &lt;code&gt;$6.25 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;1-hour cache write: &lt;code&gt;$10 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cache hit/refresh: &lt;code&gt;$0.50 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google Gemini also treats caching as a paid feature. &lt;code&gt;Gemini 3.8 Flash&lt;/code&gt; in the paid tier is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;input: &lt;code&gt;$1.50 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;output: &lt;code&gt;$3.75 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;context caching: &lt;code&gt;$0.15 / MTok&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cache storage fee starts in 2027&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, cache stable instructions and repeated reference material.&lt;/p&gt;

&lt;p&gt;But don't confuse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;discounted repetition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no consequence&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The pricing differences that actually matter
&lt;/h2&gt;

&lt;p&gt;Model pricing matters.&lt;br&gt;
Model quality matters.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;Claude Opus 4.6&lt;/code&gt; solves a planning task that &lt;code&gt;Claude Haiku 4.5&lt;/code&gt; keeps fumbling, paying more can be completely rational.&lt;/p&gt;

&lt;p&gt;If GPT-5 handles tool use or reasoning better for your workload, same story.&lt;/p&gt;

&lt;p&gt;But if your workflow is replaying irrelevant history on every step, even the cheaper model becomes an expensive habit.&lt;/p&gt;

&lt;p&gt;Here's the cleaner way to think about it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model / pricing view&lt;/th&gt;
&lt;th&gt;Key numbers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI short vs long context&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.6-terra&lt;/code&gt;: short input &lt;code&gt;$1.00/MTok&lt;/code&gt;, long input &lt;code&gt;$2.00/MTok&lt;/code&gt;; short output &lt;code&gt;$6.00/MTok&lt;/code&gt;, long output &lt;code&gt;$9.00/MTok&lt;/code&gt;; cached input &lt;code&gt;$0.10/$0.20&lt;/code&gt;; cache writes &lt;code&gt;$1.25/$2.50&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Claude Opus 4.6&lt;/td&gt;
&lt;td&gt;Input &lt;code&gt;$5/MTok&lt;/code&gt;, output &lt;code&gt;$25/MTok&lt;/code&gt;, 5-minute cache writes &lt;code&gt;$6.25/MTok&lt;/code&gt;, 1-hour cache writes &lt;code&gt;$10/MTok&lt;/code&gt;, cache hits/refreshes &lt;code&gt;$0.50/MTok&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;Input &lt;code&gt;$2/MTok&lt;/code&gt;, output &lt;code&gt;$10/MTok&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;Input &lt;code&gt;$1/MTok&lt;/code&gt;, output &lt;code&gt;$5/MTok&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;Input &lt;code&gt;$1.50/MTok&lt;/code&gt;, output &lt;code&gt;$3.75/MTok&lt;/code&gt;, context caching &lt;code&gt;$0.15/MTok&lt;/code&gt;, batch pricing is 50% lower&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only compare vendors, you'll debate forever.&lt;/p&gt;

&lt;p&gt;If you inspect your traces, you'll ask the better question:&lt;/p&gt;

&lt;p&gt;why is this workflow paying to remember things it no longer needs?&lt;/p&gt;
&lt;h2&gt;
  
  
  5 practical fixes that reduce context without breaking the workflow
&lt;/h2&gt;

&lt;p&gt;You do not need a PhD in memory architecture for this.&lt;/p&gt;

&lt;p&gt;You need rules.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Stop replaying raw tool output by default
&lt;/h3&gt;

&lt;p&gt;If Salesforce, Jira, GitHub, Discord, or your internal API returns a giant object, do not feed the whole thing back into the model unless the next step actually needs it.&lt;/p&gt;

&lt;p&gt;Summarize first.&lt;br&gt;
Store the raw payload elsewhere.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Split working memory from audit memory
&lt;/h3&gt;

&lt;p&gt;Your prompt is not your log sink.&lt;/p&gt;

&lt;p&gt;Keep detailed traces in LangSmith, OpenTelemetry, Datadog, ClickHouse, BigQuery, or wherever you want.&lt;/p&gt;

&lt;p&gt;But only pass the model what it needs for the next decision.&lt;/p&gt;

&lt;p&gt;Working memory and audit memory should be different structures.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Summarize at step boundaries
&lt;/h3&gt;

&lt;p&gt;After every major tool call, compress the result into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;facts&lt;/li&gt;
&lt;li&gt;decisions made&lt;/li&gt;
&lt;li&gt;unresolved questions&lt;/li&gt;
&lt;li&gt;next action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&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;compress_tool_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;dict&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;facts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_facts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decisions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_decisions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;open_questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_open_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;suggest_next_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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;This is one of the easiest ways to keep multi-step agent runs from ballooning.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Reset threads more often
&lt;/h3&gt;

&lt;p&gt;A lot of workflows should start fresh more often than they do.&lt;/p&gt;

&lt;p&gt;If an &lt;code&gt;n8n&lt;/code&gt; flow or &lt;code&gt;Make&lt;/code&gt; scenario is really a new job, give it a new thread.&lt;/p&gt;

&lt;p&gt;Don't let today's invoice lookup inherit yesterday's support escalation history just because both touched the same account.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Keep stable instructions stable
&lt;/h3&gt;

&lt;p&gt;Large reusable instructions can benefit from caching.&lt;/p&gt;

&lt;p&gt;But volatile task state should stay separate.&lt;/p&gt;

&lt;p&gt;A small message list is often healthier than a giant rolling transcript:&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;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&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;system&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;You are a helpful assistant.&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;Summarize the latest support ticket and decide whether it needs escalation.&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;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple beats clever more often than agent builders like to admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick way to audit your own agent
&lt;/h2&gt;

&lt;p&gt;If you want a fast sanity check, log token counts and payload sizes per step.&lt;/p&gt;

&lt;p&gt;For example:&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;log_prompt_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&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;messages&lt;/span&gt;&lt;span class="p"&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;dict&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;total_chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="sh"&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approx_chars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total_chars&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;Or if you're debugging a workflow locally, dump request bodies and inspect them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'.messages | length'&lt;/span&gt; request.json
jq &lt;span class="s1"&gt;'.messages[].role'&lt;/span&gt; request.json
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.messages[].content'&lt;/span&gt; request.json | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will usually find one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repeated system instructions&lt;/li&gt;
&lt;li&gt;giant tool payloads&lt;/li&gt;
&lt;li&gt;stale conversation turns&lt;/li&gt;
&lt;li&gt;duplicated summaries&lt;/li&gt;
&lt;li&gt;debug data that should never have been in the prompt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What if you actually need long context?
&lt;/h2&gt;

&lt;p&gt;Sometimes you do.&lt;/p&gt;

&lt;p&gt;Codebase-wide reasoning, contract analysis, compliance workflows, and deep research can genuinely need large windows.&lt;/p&gt;

&lt;p&gt;I'm not saying long context is fake.&lt;br&gt;
I'm saying default long context is lazy architecture.&lt;/p&gt;

&lt;p&gt;If you need it, use it deliberately.&lt;br&gt;
Route to it selectively.&lt;br&gt;
Budget for it.&lt;/p&gt;

&lt;p&gt;Treat expensive inference modes like an operational choice, not an accidental side effect of thread sprawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weird part: oversized context can make agents dumber
&lt;/h2&gt;

&lt;p&gt;This was the part that surprised me most.&lt;/p&gt;

&lt;p&gt;I expected bloated context to cost more.&lt;br&gt;
I didn't expect it to so reliably make agents worse.&lt;/p&gt;

&lt;p&gt;But once you see it, it's obvious.&lt;/p&gt;

&lt;p&gt;The agent starts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;anchoring on stale instructions&lt;/li&gt;
&lt;li&gt;revisiting old tool results&lt;/li&gt;
&lt;li&gt;answering the question from three turns ago&lt;/li&gt;
&lt;li&gt;slowing down because every call carries too much baggage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, model choice matters.&lt;/p&gt;

&lt;p&gt;But if your memory policy is a mess, benchmarking &lt;code&gt;GPT-5&lt;/code&gt; vs &lt;code&gt;Claude Opus 4.6&lt;/code&gt; vs &lt;code&gt;Grok 4.20&lt;/code&gt; is a little like comparing race cars while towing a dumpster.&lt;/p&gt;

&lt;h2&gt;
  
  
  My practical takeaway
&lt;/h2&gt;

&lt;p&gt;Before you switch models, inspect what your agent is dragging into every call.&lt;/p&gt;

&lt;p&gt;Cut stale turns.&lt;br&gt;
Summarize tool output.&lt;br&gt;
Separate traces from prompts.&lt;br&gt;
Reset threads more often.&lt;br&gt;
Use long context only when the task actually needs it.&lt;/p&gt;

&lt;p&gt;Most teams do not have a model problem first.&lt;/p&gt;

&lt;p&gt;They have a context hygiene problem with a pricing symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing: this is exactly why flat-rate AI compute is appealing
&lt;/h2&gt;

&lt;p&gt;If you're running agents all day in &lt;code&gt;n8n&lt;/code&gt;, &lt;code&gt;Make&lt;/code&gt;, &lt;code&gt;Zapier&lt;/code&gt;, OpenClaw, or custom workflows, per-token billing pushes teams into constant cost babysitting.&lt;/p&gt;

&lt;p&gt;And the worst part is that a lot of the cost comes from architecture mistakes like context sprawl, not from doing more useful work.&lt;/p&gt;

&lt;p&gt;That's a big reason products like Standard Compute are interesting: you get an OpenAI-compatible API, flat monthly pricing, and dynamic routing across models like GPT-5.4, Claude Opus 4.6, and Grok 4.20 without every extra tool trace turning into another finance discussion.&lt;/p&gt;

&lt;p&gt;You still want clean prompts. Bad context can still hurt quality.&lt;/p&gt;

&lt;p&gt;But removing per-token anxiety makes it much easier to build agents that run continuously without treating every long thread like a budget incident.&lt;/p&gt;

&lt;p&gt;If you're building serious automations, that's a much better default than staring at token dashboards all week.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>devops</category>
    </item>
    <item>
      <title>OAuth redirect URI mismatch ate more time than my agent logic — 5 setup changes that finally fixed Google, Slack, and HubSpot</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Sat, 19 Sep 2026 06:09:15 +0000</pubDate>
      <link>https://dev.to/lars_winstand/oauth-redirect-uri-mismatch-ate-more-time-than-my-agent-logic-5-setup-changes-that-finally-fixed-41ia</link>
      <guid>https://dev.to/lars_winstand/oauth-redirect-uri-mismatch-ate-more-time-than-my-agent-logic-5-setup-changes-that-finally-fixed-41ia</guid>
      <description>&lt;p&gt;I lost more time to &lt;code&gt;oauth redirect uri mismatch&lt;/code&gt; than to the actual agent logic.&lt;/p&gt;

&lt;p&gt;Not the Gmail classification prompt.&lt;br&gt;
Not the Slack bot scopes.&lt;br&gt;
Not the HubSpot sync code.&lt;/p&gt;

&lt;p&gt;Just OAuth.&lt;/p&gt;

&lt;p&gt;The pattern was always the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;localhost worked&lt;/li&gt;
&lt;li&gt;staging mostly worked&lt;/li&gt;
&lt;li&gt;production failed with a vague callback error&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building AI automations with Node.js, n8n, Slack apps, HubSpot apps, or Google integrations, this usually isn't random bad luck.&lt;/p&gt;

&lt;p&gt;It's architecture.&lt;/p&gt;

&lt;p&gt;The fix for us was boring but effective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one credential set per environment&lt;/li&gt;
&lt;li&gt;exact callback registration for each provider&lt;/li&gt;
&lt;li&gt;stable public base URLs&lt;/li&gt;
&lt;li&gt;logging the actual &lt;code&gt;redirect_uri&lt;/code&gt; being sent&lt;/li&gt;
&lt;li&gt;restarting workers after config changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once we did that, the "mystery OAuth bug" disappeared.&lt;/p&gt;
&lt;h2&gt;
  
  
  The real problem: your callback URL is telling two different stories
&lt;/h2&gt;

&lt;p&gt;Most teams assume OAuth failures mean the integration logic is broken.&lt;/p&gt;

&lt;p&gt;So they start debugging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prompt formatting&lt;/li&gt;
&lt;li&gt;token storage&lt;/li&gt;
&lt;li&gt;webhook handling&lt;/li&gt;
&lt;li&gt;SDK versions&lt;/li&gt;
&lt;li&gt;queue workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Meanwhile the actual problem is usually this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected redirect URI:
https://app.example.com/auth/google/callback

Actual redirect URI sent:
https://api.example.com/auth/google/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://app.example.com/auth/slack/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;vs&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.example.com/auth/slack/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://staging.example.com/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;when the provider expects HTTPS.&lt;/p&gt;

&lt;p&gt;OAuth is extremely literal.&lt;/p&gt;

&lt;p&gt;Your app can be conceptually correct and still fail because one URL string doesn't match exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bit us across Google, Slack, and HubSpot
&lt;/h2&gt;

&lt;p&gt;Here's the short version.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;What bites builders most often&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google OAuth 2.0 for Web Server Apps&lt;/td&gt;
&lt;td&gt;Redirect URI must match exactly. Teams also confuse localhost/native app patterns with real web-server OAuth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack OAuth v2&lt;/td&gt;
&lt;td&gt;If you send &lt;code&gt;redirect_uri&lt;/code&gt; in the authorize step, you must send the exact same &lt;code&gt;redirect_uri&lt;/code&gt; again during token exchange.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot OAuth 2.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;redirect_uri&lt;/code&gt; is required, production redirects must use HTTPS, and app install often fails because the user is not a Super Admin.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these providers are especially weird.&lt;/p&gt;

&lt;p&gt;The weirdness comes from our stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;frontend on one domain&lt;/li&gt;
&lt;li&gt;API on another&lt;/li&gt;
&lt;li&gt;reverse proxy in front&lt;/li&gt;
&lt;li&gt;background workers with stale env vars&lt;/li&gt;
&lt;li&gt;an automation tool generating callback URLs from the wrong base URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination creates bugs that feel random until you log the exact values.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Google: exact match means exact match
&lt;/h2&gt;

&lt;p&gt;Google was the easiest to misunderstand because local prototypes create bad habits.&lt;/p&gt;

&lt;p&gt;A lot of teams build something that works on localhost, then move it behind Vercel, Nginx, Cloud Run, or a custom Express server and assume the OAuth flow will survive the move.&lt;/p&gt;

&lt;p&gt;Sometimes it doesn't.&lt;/p&gt;

&lt;p&gt;For Google web-server OAuth, the registered redirect URI must match the one you send exactly.&lt;/p&gt;

&lt;p&gt;Not "same route, different host." Not "same callback, but HTTPS gets added later by the proxy."&lt;/p&gt;

&lt;p&gt;Exact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;Registered in Google Cloud Console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://app.example.com/auth/google/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated by app code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redirectUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/google/callback`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;PUBLIC_API_BASE_URL&lt;/code&gt; is accidentally set to:&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="nv"&gt;PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then your flow is dead before your agent code runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  A simple Node example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;googleapis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oauth2Client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OAuth2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oauth2Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAuthUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;access_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.googleapis.com/auth/gmail.readonly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.googleapis.com/auth/drive.readonly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Google auth URL:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Google redirect URI:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last log line matters more than people think.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Slack: the same redirect URI has to appear twice
&lt;/h2&gt;

&lt;p&gt;Slack was the one that felt unfair until I reread the docs carefully.&lt;/p&gt;

&lt;p&gt;The trap is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you send users to Slack's authorize URL&lt;/li&gt;
&lt;li&gt;you include &lt;code&gt;redirect_uri&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Slack sends back a code&lt;/li&gt;
&lt;li&gt;your backend exchanges the code for a token&lt;/li&gt;
&lt;li&gt;but your backend uses a different &lt;code&gt;redirect_uri&lt;/code&gt;, or omits it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: failure.&lt;/p&gt;

&lt;p&gt;If you include &lt;code&gt;redirect_uri&lt;/code&gt; during authorization, Slack expects the exact same value during token exchange.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authorize step
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat:write,channels:history&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_REDIRECT_URI&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://slack.com/oauth/v2/authorize?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Token exchange step
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_REDIRECT_URI&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://slack.com/api/oauth.v2.access&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those two &lt;code&gt;redirect_uri&lt;/code&gt; values differ, Slack is not the problem.&lt;/p&gt;

&lt;p&gt;Your app is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Another Slack gotcha: scopes are split
&lt;/h3&gt;

&lt;p&gt;Slack also separates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scope&lt;/code&gt; for bot scopes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_scope&lt;/code&gt; for user scopes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've seen teams mis-spec scopes, then blame OAuth in general.&lt;/p&gt;

&lt;p&gt;That bug looks a lot like redirect trouble at first because the install flow still fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  3) HubSpot: sometimes the URL is fine and the installer is wrong
&lt;/h2&gt;

&lt;p&gt;HubSpot gave us the most annoying fake OAuth bug.&lt;/p&gt;

&lt;p&gt;The flow itself is straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;send user to HubSpot authorize URL&lt;/li&gt;
&lt;li&gt;user approves scopes&lt;/li&gt;
&lt;li&gt;HubSpot redirects back with code&lt;/li&gt;
&lt;li&gt;backend exchanges code for token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Basic example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HUBSPOT_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HUBSPOT_REDIRECT_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crm.objects.contacts.read crm.objects.contacts.write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;installUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://app.hubspot.com/oauth/authorize?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;installUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obvious rule is that &lt;code&gt;redirect_uri&lt;/code&gt; is required.&lt;/p&gt;

&lt;p&gt;The less obvious operational rule is that production redirects need HTTPS.&lt;/p&gt;

&lt;p&gt;But the one that wasted the most time for us was permissions.&lt;/p&gt;

&lt;p&gt;A user often needs Super Admin rights to install the app in a HubSpot account.&lt;/p&gt;

&lt;p&gt;So if your rollout fails, don't immediately assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your token exchange code is wrong&lt;/li&gt;
&lt;li&gt;your callback route is broken&lt;/li&gt;
&lt;li&gt;your state parameter failed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sometimes the installer just isn't allowed to approve the app.&lt;/p&gt;

&lt;p&gt;That is not a code bug. It's an account-role problem wearing a code-bug costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 setup changes that finally fixed it
&lt;/h2&gt;

&lt;p&gt;This is the part I wish someone had handed me on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One credential set per environment
&lt;/h2&gt;

&lt;p&gt;Stop trying to make one OAuth app serve localhost, staging, and production cleanly.&lt;/p&gt;

&lt;p&gt;Use separate credentials.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google OAuth client for local&lt;/li&gt;
&lt;li&gt;Google OAuth client for staging&lt;/li&gt;
&lt;li&gt;Google OAuth client for prod&lt;/li&gt;
&lt;li&gt;separate Slack app config or redirect setup per environment&lt;/li&gt;
&lt;li&gt;separate HubSpot app settings where needed&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example env files
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.local&lt;/span&gt;
&lt;span class="nv"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:3000/auth/google/callback
&lt;span class="nv"&gt;SLACK_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:3000/auth/slack/callback
&lt;span class="nv"&gt;HUBSPOT_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:3000/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.staging&lt;/span&gt;
&lt;span class="nv"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://staging.example.com/auth/google/callback
&lt;span class="nv"&gt;SLACK_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://staging.example.com/auth/slack/callback
&lt;span class="nv"&gt;HUBSPOT_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://staging.example.com/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.production&lt;/span&gt;
&lt;span class="nv"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com/auth/google/callback
&lt;span class="nv"&gt;SLACK_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com/auth/slack/callback
&lt;span class="nv"&gt;HUBSPOT_REDIRECT_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This feels tedious right up until it saves you two days.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Register every exact callback URL
&lt;/h2&gt;

&lt;p&gt;Not just the base domain.&lt;/p&gt;

&lt;p&gt;Not just one callback path you hope can cover everything.&lt;/p&gt;

&lt;p&gt;Register the exact callback URLs the provider will see.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:3000/auth/google/callback
https://staging.example.com/auth/google/callback
https://app.example.com/auth/google/callback
https://app.example.com/auth/slack/callback
https://app.example.com/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your app uses different subdomains for API and UI, decide which one owns the callback and stick to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Use one stable public base URL
&lt;/h2&gt;

&lt;p&gt;A lot of OAuth bugs are really bad URL generation.&lt;/p&gt;

&lt;p&gt;The app builds redirect URIs from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request headers&lt;/li&gt;
&lt;li&gt;internal container hostnames&lt;/li&gt;
&lt;li&gt;editor URLs&lt;/li&gt;
&lt;li&gt;proxy-forwarded hosts&lt;/li&gt;
&lt;li&gt;stale env vars&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is how you end up with callbacks pointing at the wrong domain.&lt;/p&gt;

&lt;p&gt;I prefer making the public base URL explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PUBLIC_APP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://app.example.com
&lt;span class="nv"&gt;PUBLIC_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then build callbacks from one canonical value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/google/callback`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SLACK_REDIRECT_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/slack/callback`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HUBSPOT_REDIRECT_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/hubspot/callback`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're using n8n or similar tools, the public URL config matters a lot.&lt;/p&gt;

&lt;p&gt;Example:&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;export &lt;/span&gt;&lt;span class="nv"&gt;VUE_APP_URL_BASE_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://n8n.example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the editor thinks it's running at one URL and the backend tells Google or Slack another story, you'll keep chasing ghosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Log the outbound authorize URL and inbound callback details
&lt;/h2&gt;

&lt;p&gt;This was the biggest practical win.&lt;/p&gt;

&lt;p&gt;Log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full authorize URL&lt;/li&gt;
&lt;li&gt;exact &lt;code&gt;redirect_uri&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;exact &lt;code&gt;state&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;callback host/path/query&lt;/li&gt;
&lt;li&gt;token exchange payload fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not secrets, obviously.&lt;/p&gt;

&lt;p&gt;Just enough to compare what you intended with what actually happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Express middleware example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/google/start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redirectUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_REDIRECT_URI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oauth2Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAuthUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;access_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.googleapis.com/auth/gmail.readonly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:start] redirect_uri=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:start] state=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:start] authUrl=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/google/callback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:callback] host=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:callback] originalUrl=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[google:callback] query=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&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;The first time you compare these logs across local, staging, and prod, the mismatch usually becomes obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Restart workers after config changes
&lt;/h2&gt;

&lt;p&gt;This sounds embarrassingly basic because it is.&lt;/p&gt;

&lt;p&gt;But long-running processes love stale config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;queue consumers&lt;/li&gt;
&lt;li&gt;webhook handlers&lt;/li&gt;
&lt;li&gt;background job runners&lt;/li&gt;
&lt;li&gt;AI agent workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You update the dashboard or environment variables, but a worker still has the old redirect base cached in memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Typical restart commands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 restart all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout restart deployment/my-api
kubectl rollout restart deployment/my-workers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had workers generating old callback domains hours after the fix was supposedly deployed.&lt;/p&gt;

&lt;p&gt;That bug felt supernatural until we realized the processes had never reloaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick debugging checklist
&lt;/h2&gt;

&lt;p&gt;When OAuth fails, this is the checklist I use now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redirect URI sanity check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$GOOGLE_REDIRECT_URI&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$SLACK_REDIRECT_URI&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$HUBSPOT_REDIRECT_URI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Confirm what the app is actually sending
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"redirect_uri"&lt;/span&gt; ./src
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Inspect the callback route in logs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; logs/app.log | &lt;span class="nb"&gt;grep &lt;/span&gt;callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify HTTPS in production
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://app.example.com/auth/google/callback
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://app.example.com/auth/slack/callback
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://app.example.com/auth/hubspot/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check for proxy/header weirdness
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trust proxy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;forwardedProto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-proto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;forwardedHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;next&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;If your app is behind a proxy and doesn't trust forwarded headers correctly, it may generate the wrong public URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question that can remove a lot of OAuth pain
&lt;/h2&gt;

&lt;p&gt;Do you actually need user OAuth?&lt;/p&gt;

&lt;p&gt;A lot of internal automations don't.&lt;/p&gt;

&lt;p&gt;If you're acting on shared infrastructure or project-owned resources, service accounts can be much simpler than user-consent flows.&lt;/p&gt;

&lt;p&gt;That won't replace Slack workspace installs or HubSpot app installs, obviously.&lt;/p&gt;

&lt;p&gt;But for some Google workflows, it's the difference between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;handling consent screens&lt;/li&gt;
&lt;li&gt;storing refresh tokens&lt;/li&gt;
&lt;li&gt;managing callback URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and just using system-level credentials.&lt;/p&gt;

&lt;p&gt;Ask that question early.&lt;/p&gt;

&lt;p&gt;It can save a stupid amount of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more for AI agents and automations
&lt;/h2&gt;

&lt;p&gt;This problem gets worse when your stack includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;n8n&lt;/li&gt;
&lt;li&gt;Make&lt;/li&gt;
&lt;li&gt;Zapier&lt;/li&gt;
&lt;li&gt;custom Node.js backends&lt;/li&gt;
&lt;li&gt;Slack bots&lt;/li&gt;
&lt;li&gt;CRM sync workers&lt;/li&gt;
&lt;li&gt;Gmail or Drive agents&lt;/li&gt;
&lt;li&gt;long-running background jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agent systems are already distributed.&lt;/p&gt;

&lt;p&gt;Now add OAuth on top and every blurry architecture boundary gets exposed immediately.&lt;/p&gt;

&lt;p&gt;That's why these bugs feel so expensive.&lt;/p&gt;

&lt;p&gt;You're not just debugging one app.&lt;/p&gt;

&lt;p&gt;You're debugging identity across multiple services, multiple environments, and multiple callback assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I took from this
&lt;/h2&gt;

&lt;p&gt;OAuth isn't hard because the protocol is mysterious.&lt;/p&gt;

&lt;p&gt;It's hard because it forces your architecture to stop lying.&lt;/p&gt;

&lt;p&gt;If local, staging, and production are fuzzy, OAuth will find the fuzziness.&lt;/p&gt;

&lt;p&gt;If your callback URL depends on whichever host answered the request, OAuth will punish that.&lt;/p&gt;

&lt;p&gt;If your HubSpot installer doesn't have Super Admin rights, no amount of token debugging will save you.&lt;/p&gt;

&lt;p&gt;The practical fix is boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separate credentials by environment&lt;/li&gt;
&lt;li&gt;register exact callback URLs&lt;/li&gt;
&lt;li&gt;use a stable public base URL&lt;/li&gt;
&lt;li&gt;log what you actually send&lt;/li&gt;
&lt;li&gt;restart every process that might cache config&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do that, and your agents can go back to failing for interesting reasons.&lt;/p&gt;




&lt;p&gt;One last thing: this is exactly the kind of problem that gets worse when every retry, every background worker run, and every agent loop has a per-token or per-call cost attached.&lt;/p&gt;

&lt;p&gt;If you're running lots of AI automations and agent workflows, predictable infrastructure matters just as much as correct OAuth setup. That's a big part of why Standard Compute exists: flat monthly AI compute for teams running automations all day, without watching token burn every time a workflow retries.&lt;/p&gt;

&lt;p&gt;If that's your situation, Standard Compute is worth a look: &lt;a href="https://standardcompute.com" rel="noopener noreferrer"&gt;https://standardcompute.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>node</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>The most expensive part of my agent stack wasn’t tokens — it was the invoice roulette</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Thu, 17 Sep 2026 22:09:54 +0000</pubDate>
      <link>https://dev.to/lars_winstand/the-most-expensive-part-of-my-agent-stack-wasnt-tokens-it-was-the-invoice-roulette-1ohk</link>
      <guid>https://dev.to/lars_winstand/the-most-expensive-part-of-my-agent-stack-wasnt-tokens-it-was-the-invoice-roulette-1ohk</guid>
      <description>&lt;p&gt;I hit a weird point with my agent stack a few months ago.&lt;/p&gt;

&lt;p&gt;Not when prompts were bad.&lt;br&gt;
Not when tools were failing.&lt;br&gt;
Not when n8n was half-broken.&lt;/p&gt;

&lt;p&gt;It happened after everything started working.&lt;/p&gt;

&lt;p&gt;The flows were stable. The AI Agent node in n8n was calling tools correctly. OpenClaw was keeping assistants alive across Slack and Discord. Background jobs were running. Users were happy.&lt;/p&gt;

&lt;p&gt;Then I opened the invoice.&lt;/p&gt;

&lt;p&gt;And I had that very specific engineering feeling: I understood every moving part individually, but I still could not predict the total cost.&lt;/p&gt;

&lt;p&gt;That was the moment I stopped thinking about AI cost as “price per token” and started thinking about billing volatility as an architecture problem.&lt;/p&gt;

&lt;p&gt;If you run agents 24/7, especially across n8n, Make, Zapier, OpenClaw, or custom workflows, this matters more than most pricing pages admit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap: tokens are not the real unit of cost
&lt;/h2&gt;

&lt;p&gt;At prototype stage, token math feels clean.&lt;/p&gt;

&lt;p&gt;You estimate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;average prompt size&lt;/li&gt;
&lt;li&gt;average response size&lt;/li&gt;
&lt;li&gt;expected request volume&lt;/li&gt;
&lt;li&gt;maybe a little buffer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That works for chat demos.&lt;/p&gt;

&lt;p&gt;It breaks for automations.&lt;/p&gt;

&lt;p&gt;An agent workflow is not one request. It is a chain reaction.&lt;/p&gt;

&lt;p&gt;A single inbound event can trigger:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one LLM call to interpret the task&lt;/li&gt;
&lt;li&gt;one or more tool calls&lt;/li&gt;
&lt;li&gt;another LLM call to summarize tool output&lt;/li&gt;
&lt;li&gt;retries after a timeout&lt;/li&gt;
&lt;li&gt;fallback to another provider after a rate limit&lt;/li&gt;
&lt;li&gt;an error workflow that replays part of the execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What looked like “1 event = 1 model call” turns into “1 event = 7 billable things plus side effects.”&lt;/p&gt;

&lt;p&gt;That is why production cost gets weird fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  A simple example from n8n
&lt;/h2&gt;

&lt;p&gt;Here is the kind of flow that looks cheap on a whiteboard and expensive in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Webhook receives a support ticket&lt;/li&gt;
&lt;li&gt;n8n AI Agent classifies urgency&lt;/li&gt;
&lt;li&gt;Agent calls CRM tool&lt;/li&gt;
&lt;li&gt;Agent calls search or knowledge base tool&lt;/li&gt;
&lt;li&gt;Agent drafts a reply&lt;/li&gt;
&lt;li&gt;Timeout happens on step 3&lt;/li&gt;
&lt;li&gt;Execution retries&lt;/li&gt;
&lt;li&gt;Draft step runs again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now the pricing problem is not prompt length.&lt;/p&gt;

&lt;p&gt;It is workflow behavior.&lt;/p&gt;

&lt;p&gt;If you are tracking retries in n8n, you already know this pattern exists. Retry metadata like &lt;code&gt;execution.retryOf&lt;/code&gt; is a reminder that retries are normal in automation, not rare edge cases.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bill changes even when prompts don’t
&lt;/h2&gt;

&lt;p&gt;This is the part that made me stop trusting per-token forecasts.&lt;/p&gt;

&lt;p&gt;People describe usage-based pricing as transparent. In practice, for agent systems, it often isn’t.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because your bill is shaped by more than token volume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request bursts&lt;/li&gt;
&lt;li&gt;rate-limit backoffs&lt;/li&gt;
&lt;li&gt;queueing&lt;/li&gt;
&lt;li&gt;fallback chains&lt;/li&gt;
&lt;li&gt;cache hit rate&lt;/li&gt;
&lt;li&gt;background jobs&lt;/li&gt;
&lt;li&gt;sync vs batch routing&lt;/li&gt;
&lt;li&gt;tool fan-out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two months can have roughly the same user demand and still produce different costs.&lt;/p&gt;

&lt;p&gt;Not because prompts changed.&lt;br&gt;
Because execution shape changed.&lt;/p&gt;

&lt;p&gt;Maybe traffic got burstier.&lt;br&gt;
Maybe a nightly summarization job collided with interactive traffic.&lt;br&gt;
Maybe one provider throttled and your fallback path activated more often.&lt;br&gt;
Maybe your cache hit rate dropped because prompt prefixes drifted.&lt;/p&gt;

&lt;p&gt;That is invoice roulette.&lt;/p&gt;
&lt;h2&gt;
  
  
  Background jobs are where pricing models go to die
&lt;/h2&gt;

&lt;p&gt;The least honest part of many AI cost estimates is that they ignore background work.&lt;/p&gt;

&lt;p&gt;But real agent systems are full of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nightly classifiers&lt;/li&gt;
&lt;li&gt;memory refresh jobs&lt;/li&gt;
&lt;li&gt;ticket summarization&lt;/li&gt;
&lt;li&gt;thread cleanup&lt;/li&gt;
&lt;li&gt;webhook-triggered enrichments&lt;/li&gt;
&lt;li&gt;multi-channel assistants sitting idle-but-not-really-idle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An OpenClaw assistant connected to Slack, Discord, Telegram, WhatsApp, and Teams may look quiet from the outside.&lt;/p&gt;

&lt;p&gt;It is still maintaining context, reacting to events, preserving sessions, and sometimes running scheduled tasks.&lt;/p&gt;

&lt;p&gt;That means cost keeps accumulating even when no human is actively chatting.&lt;/p&gt;
&lt;h2&gt;
  
  
  The vendor pricing features are useful — and also kind of a confession
&lt;/h2&gt;

&lt;p&gt;This was the part that surprised me most.&lt;/p&gt;

&lt;p&gt;Every major model vendor now has pricing features designed to reduce cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;batch APIs&lt;/li&gt;
&lt;li&gt;prompt caching&lt;/li&gt;
&lt;li&gt;context caching&lt;/li&gt;
&lt;li&gt;separate rate-limit pools&lt;/li&gt;
&lt;li&gt;grounding quotas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These features are real and useful.&lt;/p&gt;

&lt;p&gt;They also quietly admit the same thing: raw synchronous per-request billing is a bad fit for a lot of production automation.&lt;/p&gt;
&lt;h2&gt;
  
  
  OpenAI Batch: good feature, loud signal
&lt;/h2&gt;

&lt;p&gt;OpenAI Batch is a solid option for offline work.&lt;/p&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50% lower cost than synchronous API usage&lt;/li&gt;
&lt;li&gt;separate, higher-rate-limit capacity&lt;/li&gt;
&lt;li&gt;completion within 24 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is great for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bulk classification&lt;/li&gt;
&lt;li&gt;enrichment&lt;/li&gt;
&lt;li&gt;nightly summarization&lt;/li&gt;
&lt;li&gt;offline evaluation jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example shape:&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="c"&gt;# pseudo-workflow&lt;/span&gt;
&lt;span class="c"&gt;# 1. collect jobs during the day&lt;/span&gt;
&lt;span class="c"&gt;# 2. ship them to batch overnight&lt;/span&gt;
&lt;span class="c"&gt;# 3. read results later&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pricing win is real.&lt;/p&gt;

&lt;p&gt;But the architectural implication matters more: now cost depends on execution mode, not just token count.&lt;/p&gt;

&lt;p&gt;You are no longer asking “how many tokens did I send?”&lt;br&gt;
You are asking “which jobs can tolerate delay, and did I route them correctly?”&lt;/p&gt;

&lt;p&gt;That is a workflow design problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  Anthropic prompt caching: powerful, but easy to overestimate
&lt;/h2&gt;

&lt;p&gt;Anthropic caching looks fantastic on paper.&lt;/p&gt;

&lt;p&gt;Typical pricing structure for Claude tiers includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;base input pricing&lt;/li&gt;
&lt;li&gt;separate cache write pricing&lt;/li&gt;
&lt;li&gt;much cheaper cache hit pricing&lt;/li&gt;
&lt;li&gt;output pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That can be a huge win if your prompt prefixes are stable and requests arrive inside the cache window.&lt;/p&gt;

&lt;p&gt;Example:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;claude-opus-4-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a support triage assistant.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;Classify this ticket and suggest next action.&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;span class="n"&gt;cache_control&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;type&lt;/span&gt;&lt;span class="sh"&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;ephemeral&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;The problem is forecasting.&lt;/p&gt;

&lt;p&gt;Your savings now depend on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether prefixes actually stay stable&lt;/li&gt;
&lt;li&gt;whether requests land inside the cache lifetime&lt;/li&gt;
&lt;li&gt;whether your agent architecture reuses context consistently&lt;/li&gt;
&lt;li&gt;whether tool outputs keep mutating the prompt shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teams often assume caching will save a lot, then discover their traffic pattern is too messy.&lt;/p&gt;

&lt;p&gt;For always-on agents, this is one of the easiest optimizations to model optimistically and realize pessimistically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Gemini Batch and caching: same story, more variables
&lt;/h2&gt;

&lt;p&gt;Google Gemini has the same pattern.&lt;/p&gt;

&lt;p&gt;There are real discounts for batch execution. There is context caching. There can also be extra costs around storage duration and grounding.&lt;/p&gt;

&lt;p&gt;Example batch call shape:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;gemini-2.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&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;contents&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;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;parts&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&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;Summarize this ticket in one sentence.&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;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;config&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;display_name&lt;/span&gt;&lt;span class="sh"&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;ticket-summary-batch&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;Again, nothing is wrong with this.&lt;/p&gt;

&lt;p&gt;But now your cost model depends on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sync vs batch routing&lt;/li&gt;
&lt;li&gt;cache duration&lt;/li&gt;
&lt;li&gt;storage time&lt;/li&gt;
&lt;li&gt;grounding frequency&lt;/li&gt;
&lt;li&gt;request shape over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not simple token accounting anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which cost-saving feature is actually worth using?
&lt;/h2&gt;

&lt;p&gt;My opinion, after dealing with this in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Best use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Batch&lt;/td&gt;
&lt;td&gt;Best for boring offline jobs like nightly classification, summarization, and enrichment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic prompt caching&lt;/td&gt;
&lt;td&gt;Best when prompts are highly stable and traffic repeatedly hits the same prefixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Gemini Batch + caching&lt;/td&gt;
&lt;td&gt;Best when you can cleanly separate async work and actually manage caching/grounding behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My practical ranking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Batch APIs are the easiest clear win&lt;/li&gt;
&lt;li&gt;Prompt caching is useful but easier to overestimate&lt;/li&gt;
&lt;li&gt;Complex multi-provider fallback systems create the worst forecasting problems&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you run an always-on assistant across Slack, Discord, Telegram, Teams, or WhatsApp, with memory, tools, webhooks, and scheduled jobs, the hardest part is not finding a low token price.&lt;/p&gt;

&lt;p&gt;It is explaining next month’s bill before next month happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem is invoice volatility
&lt;/h2&gt;

&lt;p&gt;This is the shift that changed how I think about AI infrastructure.&lt;/p&gt;

&lt;p&gt;The expensive part is not always the model.&lt;br&gt;
Sometimes it is the unpredictability.&lt;/p&gt;

&lt;p&gt;That matters because unpredictable spend changes engineering behavior.&lt;/p&gt;

&lt;p&gt;Teams start doing weird things when they cannot trust the bill:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;throttling useful features too early&lt;/li&gt;
&lt;li&gt;avoiding background automation that would actually help users&lt;/li&gt;
&lt;li&gt;over-optimizing prompts instead of fixing workflow design&lt;/li&gt;
&lt;li&gt;delaying launches because finance wants tighter cost bounds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why predictable pricing becomes attractive long before raw per-token pricing becomes objectively expensive.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I do now instead of naive token forecasting
&lt;/h2&gt;

&lt;p&gt;I still care about model pricing.&lt;br&gt;
I just do forecasting differently.&lt;/p&gt;

&lt;p&gt;I model execution paths, not average prompts.&lt;/p&gt;

&lt;p&gt;That means I count:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries per workflow&lt;/li&gt;
&lt;li&gt;average tool fan-out&lt;/li&gt;
&lt;li&gt;fallback frequency&lt;/li&gt;
&lt;li&gt;sync vs batch split&lt;/li&gt;
&lt;li&gt;cache hit assumptions&lt;/li&gt;
&lt;li&gt;background job frequency&lt;/li&gt;
&lt;li&gt;burst behavior under load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rough spreadsheet is still useful, but only if it reflects system behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  A better way to think about cost
&lt;/h2&gt;

&lt;p&gt;Bad forecast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monthly_cost = avg_prompt_tokens * avg_response_tokens * requests * token_price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better forecast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monthly_cost =
  interactive_requests * avg_interactive_execution_path
+ batch_requests * avg_batch_execution_path
+ background_jobs * avg_background_execution_path
+ retry_overhead
+ fallback_overhead
+ cache_miss_penalty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much closer to reality for agent systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability matters more when pricing gets harder to reason about
&lt;/h2&gt;

&lt;p&gt;If your workflows are complex, tracing becomes non-negotiable.&lt;/p&gt;

&lt;p&gt;For example, if you are debugging model behavior with LangSmith and OpenAI-compatible tooling:&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;export &lt;/span&gt;&lt;span class="nv"&gt;LANGSMITH_TRACING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
export &lt;/span&gt;&lt;span class="nv"&gt;LANGSMITH_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-langsmith-api-key&amp;gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-openai-api-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That will not reduce the bill by itself.&lt;/p&gt;

&lt;p&gt;But it helps answer the question that always shows up too late:&lt;/p&gt;

&lt;p&gt;Why did this workflow call the model four times?&lt;/p&gt;

&lt;p&gt;Without tracing, cost debugging turns into archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  When usage-based pricing is still totally fine
&lt;/h2&gt;

&lt;p&gt;I do not think every team should abandon per-token billing.&lt;/p&gt;

&lt;p&gt;It is still a good fit when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Traffic is low-volume&lt;/li&gt;
&lt;li&gt;Workflows are simple&lt;/li&gt;
&lt;li&gt;Prompts are stable&lt;/li&gt;
&lt;li&gt;Retries are rare&lt;/li&gt;
&lt;li&gt;Background jobs are limited&lt;/li&gt;
&lt;li&gt;You can actually use batch or caching reliably&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In that world, usage-based pricing can absolutely be cheaper.&lt;/p&gt;

&lt;p&gt;But once agents run continuously, touch tools, operate across channels, and keep doing work while you sleep, predictability starts to matter more than benchmark token price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where flat-rate compute starts making more sense
&lt;/h2&gt;

&lt;p&gt;This is exactly why products like Standard Compute exist.&lt;/p&gt;

&lt;p&gt;If your stack already speaks the OpenAI API, swapping endpoints is much easier than rebuilding your workflows around five different pricing tricks.&lt;/p&gt;

&lt;p&gt;The appeal is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;flat monthly pricing&lt;/li&gt;
&lt;li&gt;no per-token billing&lt;/li&gt;
&lt;li&gt;works with OpenAI-compatible SDKs and HTTP clients&lt;/li&gt;
&lt;li&gt;better fit for always-on agents and automations&lt;/li&gt;
&lt;li&gt;less time spent playing pricing Tetris across GPT, Claude, and Grok&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not remove the need for good architecture.&lt;/p&gt;

&lt;p&gt;You still need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tracing&lt;/li&gt;
&lt;li&gt;guardrails&lt;/li&gt;
&lt;li&gt;sane retry policies&lt;/li&gt;
&lt;li&gt;separation between real-time and batch work&lt;/li&gt;
&lt;li&gt;good tool design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But it removes one category of chaos: surprise invoices caused by workflow behavior you did not model perfectly.&lt;/p&gt;

&lt;p&gt;For teams running n8n, Make, Zapier, OpenClaw, or custom agents, that tradeoff is often worth more than squeezing out the cheapest theoretical token path.&lt;/p&gt;

&lt;h2&gt;
  
  
  My takeaway
&lt;/h2&gt;

&lt;p&gt;The clean mental model is wrong.&lt;/p&gt;

&lt;p&gt;AI automation cost is not just model quality multiplied by token count.&lt;/p&gt;

&lt;p&gt;It is the sum of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;rate limits&lt;/li&gt;
&lt;li&gt;fallback chains&lt;/li&gt;
&lt;li&gt;cache windows&lt;/li&gt;
&lt;li&gt;batch queues&lt;/li&gt;
&lt;li&gt;grounding requests&lt;/li&gt;
&lt;li&gt;tool loops&lt;/li&gt;
&lt;li&gt;background jobs&lt;/li&gt;
&lt;li&gt;all the tiny workflow decisions that compound at scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your biggest monthly question is no longer “which model is cheapest per token?” but “why can’t I predict this invoice at all?”&lt;/p&gt;

&lt;p&gt;That is not a finance problem.&lt;/p&gt;

&lt;p&gt;That is architecture.&lt;/p&gt;

&lt;p&gt;And once you see it that way, a lot of pricing decisions start looking very different.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>n8n</category>
      <category>openai</category>
    </item>
    <item>
      <title>I built a headless Mac mini AI server and by week two I was debugging sleep, plist files, and Redis</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Thu, 17 Sep 2026 14:10:25 +0000</pubDate>
      <link>https://dev.to/lars_winstand/i-built-a-headless-mac-mini-ai-server-and-by-week-two-i-was-debugging-sleep-plist-files-and-redis-2c9g</link>
      <guid>https://dev.to/lars_winstand/i-built-a-headless-mac-mini-ai-server-and-by-week-two-i-was-debugging-sleep-plist-files-and-redis-2c9g</guid>
      <description>&lt;p&gt;The moment I knew my cute little local AI box had turned into actual infrastructure was when a folder stopped moving files at 3:14 a.m.&lt;/p&gt;

&lt;p&gt;Nothing crashed.&lt;/p&gt;

&lt;p&gt;Disk was fine.&lt;/p&gt;

&lt;p&gt;Ollama was still up.&lt;/p&gt;

&lt;p&gt;The machine had just quietly stopped being useful.&lt;/p&gt;

&lt;p&gt;That was my headless Mac mini setup: a Mac mini on a shelf, no monitor, Ollama serving local models on &lt;code&gt;http://localhost:11434/v1&lt;/code&gt;, a few Python helpers, and n8n running background automations.&lt;/p&gt;

&lt;p&gt;For six days, it felt elegant.&lt;/p&gt;

&lt;p&gt;Then week two started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first real failure wasn't inference
&lt;/h2&gt;

&lt;p&gt;I expected model serving to be the hard part.&lt;/p&gt;

&lt;p&gt;Maybe Llama would be too slow.&lt;br&gt;
Maybe Qwen would eat RAM.&lt;br&gt;
Maybe Ollama would fall over under concurrent requests.&lt;/p&gt;

&lt;p&gt;Nope.&lt;/p&gt;

&lt;p&gt;The first reliability problem was sleep.&lt;/p&gt;

&lt;p&gt;A headless Mac mini loves to look alive while doing nothing useful.&lt;/p&gt;

&lt;p&gt;Your file watcher still exists.&lt;br&gt;
Your local API still responds sometimes.&lt;br&gt;
Your helper process is technically running.&lt;/p&gt;

&lt;p&gt;But if the machine slept, or the session changed in a way your setup didn't handle, your "automation server" became desktop theater.&lt;/p&gt;

&lt;p&gt;That was the first lesson:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you don't explicitly manage power behavior, you're not running a server. You're running a desktop that occasionally pretends to be one.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The two commands that fixed the obvious nonsense
&lt;/h2&gt;

&lt;p&gt;If you're trying to keep a headless Mac mini alive for background jobs, you meet &lt;code&gt;caffeinate&lt;/code&gt; and &lt;code&gt;pmset&lt;/code&gt; fast.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;caffeinate &lt;span class="nt"&gt;-i&lt;/span&gt; python3 watch.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prevents idle sleep while the command runs.&lt;/p&gt;

&lt;p&gt;For broader power settings:&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;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;0
pmset &lt;span class="nt"&gt;-g&lt;/span&gt; assertions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does two useful things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;disables system sleep across power profiles&lt;/li&gt;
&lt;li&gt;shows which processes are currently asserting power management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;caffeinate &lt;span class="nt"&gt;-i&lt;/span&gt; ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or wrapping a long-running worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;caffeinate &lt;span class="nt"&gt;-i&lt;/span&gt; /usr/local/bin/python3 /Users/you/ai-helper/watch.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One footgun worth calling out: &lt;code&gt;caffeinate -u&lt;/code&gt; defaults to a &lt;strong&gt;5 second timeout&lt;/strong&gt; if you don't pass &lt;code&gt;-t&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of detail that makes a test look fine and an overnight job fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;launchd&lt;/code&gt; matters more than another shell script
&lt;/h2&gt;

&lt;p&gt;A lot of people approach this with Linux habits.&lt;/p&gt;

&lt;p&gt;Start a process in the background.&lt;br&gt;
Add &lt;code&gt;&amp;amp;&lt;/code&gt;.&lt;br&gt;
Maybe use &lt;code&gt;nohup&lt;/code&gt;.&lt;br&gt;
Call it a service.&lt;/p&gt;

&lt;p&gt;On macOS, that gets messy fast.&lt;/p&gt;

&lt;p&gt;If the Mac mini is going to be headless and useful, &lt;code&gt;launchd&lt;/code&gt; is the real primitive.&lt;/p&gt;

&lt;p&gt;And Apple is pretty clear about one thing: jobs launched by &lt;code&gt;launchd&lt;/code&gt; should not daemonize themselves with the old fork-and-exit pattern.&lt;/p&gt;

&lt;p&gt;That means a lot of generic "run this as a service" tutorials are subtly wrong for macOS.&lt;/p&gt;

&lt;p&gt;Once I stopped fighting that, things got cleaner.&lt;/p&gt;
&lt;h2&gt;
  
  
  A minimal &lt;code&gt;launchd&lt;/code&gt; watcher that actually behaves like a service
&lt;/h2&gt;

&lt;p&gt;For file-triggered helpers, a plist beats a Terminal tab every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.example.aihelper&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/usr/local/bin/python3&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/you/ai-helper/watch.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;KeepAlive&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;WatchPaths&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/you/Inbox&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardOutPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;/tmp/aihelper.out.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardErrorPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;/tmp/aihelper.err.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three keys doing most of the work are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KeepAlive&lt;/code&gt;: restart the helper if it dies&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WatchPaths&lt;/code&gt;: react to directory changes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RunAtLoad&lt;/code&gt;: start immediately when loaded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Load it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl load ~/Library/LaunchAgents/com.example.aihelper.plist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or on newer macOS versions, bootstrap it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootstrap gui/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; ~/Library/LaunchAgents/com.example.aihelper.plist
launchctl kickstart &lt;span class="nt"&gt;-k&lt;/span&gt; gui/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/com.example.aihelper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl print gui/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/com.example.aihelper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tail logs:&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;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/aihelper.out.log /tmp/aihelper.err.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the point where your Mac mini starts acting like a background worker instead of laptop cosplay.&lt;/p&gt;

&lt;p&gt;The downside is also immediate.&lt;/p&gt;

&lt;p&gt;You stop debugging only your Python code.&lt;br&gt;
Now you're debugging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;plist syntax&lt;/li&gt;
&lt;li&gt;&lt;code&gt;launchctl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;process environment differences&lt;/li&gt;
&lt;li&gt;restart behavior&lt;/li&gt;
&lt;li&gt;macOS logging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a very different hobby.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ollama is easy right up until it becomes infrastructure
&lt;/h2&gt;

&lt;p&gt;I still think Ollama is the easiest on-ramp for local AI.&lt;/p&gt;

&lt;p&gt;Point your app at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:11434/v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and use the OpenAI-compatible API.&lt;/p&gt;

&lt;p&gt;That means existing SDK code changes very little.&lt;/p&gt;

&lt;p&gt;Example with Python:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;llama3.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="sh"&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;user&lt;/span&gt;&lt;span class="sh"&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;content&lt;/span&gt;&lt;span class="sh"&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;Summarize this file in 3 bullets.&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;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&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;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For experiments, this rules.&lt;/p&gt;

&lt;p&gt;You can swap models, test a summarizer, wire up an agent, and get the "wait, this actually works" feeling in under an hour.&lt;/p&gt;

&lt;p&gt;Then you realize Ollama is not a vibe.&lt;/p&gt;

&lt;p&gt;It's a server.&lt;/p&gt;

&lt;p&gt;And a server needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;startup behavior&lt;/li&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;restart behavior&lt;/li&gt;
&lt;li&gt;predictable uptime&lt;/li&gt;
&lt;li&gt;some answer to "what happens after a crash?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your AI helper is doing useful work while you're asleep, a Terminal tab is not a deployment strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real pain starts when one automation becomes five
&lt;/h2&gt;

&lt;p&gt;This is where local AI setups get oversold.&lt;/p&gt;

&lt;p&gt;A single user with a few lightweight jobs? Great.&lt;/p&gt;

&lt;p&gt;A Mac mini with &lt;code&gt;launchd&lt;/code&gt;, Ollama, and a couple scripts can be genuinely nice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;private&lt;/li&gt;
&lt;li&gt;fast on LAN&lt;/li&gt;
&lt;li&gt;fixed hardware cost&lt;/li&gt;
&lt;li&gt;no cloud bill anxiety&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;webhooks&lt;/li&gt;
&lt;li&gt;file triggers&lt;/li&gt;
&lt;li&gt;scheduled jobs&lt;/li&gt;
&lt;li&gt;overlapping LLM calls&lt;/li&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;multiple users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and your tiny local setup starts acting like a small production system.&lt;/p&gt;

&lt;p&gt;That is where orchestration becomes the problem, not inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  n8n gets real about concurrency faster than most people do
&lt;/h2&gt;

&lt;p&gt;n8n is a good example because the docs are pretty honest.&lt;/p&gt;

&lt;p&gt;In regular mode, production executions can pile up unless you cap them.&lt;/p&gt;

&lt;p&gt;The practical env var is:&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;export &lt;/span&gt;&lt;span class="nv"&gt;N8N_CONCURRENCY_PRODUCTION_LIMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Docker:&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_CONCURRENCY_PRODUCTION_LIMIT=20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds like a tuning detail.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;It's the point where you admit overlapping work can absolutely make one box unresponsive.&lt;/p&gt;

&lt;p&gt;And once you need more than one busy process path, the architecture changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queue mode is where your "local box" becomes a system
&lt;/h2&gt;

&lt;p&gt;n8n queue mode exists for a reason.&lt;/p&gt;

&lt;p&gt;It separates trigger handling from execution workers and uses Redis in the middle.&lt;/p&gt;

&lt;p&gt;That's the right move when jobs overlap heavily.&lt;/p&gt;

&lt;p&gt;It's also the point where your Mac mini is no longer just a local automation box.&lt;/p&gt;

&lt;p&gt;Now you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an n8n main instance&lt;/li&gt;
&lt;li&gt;one or more workers&lt;/li&gt;
&lt;li&gt;Redis&lt;/li&gt;
&lt;li&gt;shared encryption key management&lt;/li&gt;
&lt;li&gt;a database setup that should not be SQLite&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a real operational jump.&lt;/p&gt;

&lt;p&gt;Here's the cleanest summary I can give:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What week two feels like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;launchd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native macOS fit for headless jobs, solid for watchers and helpers, but debugging moves into plist files and system behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n regular mode with concurrency cap&lt;/td&gt;
&lt;td&gt;Fine for a single instance and moderate load, but easy to outgrow once executions overlap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n queue mode&lt;/td&gt;
&lt;td&gt;Much better scaling story, but now you're operating Redis, workers, and actual workflow infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the local-first tax.&lt;/p&gt;

&lt;p&gt;Not API compatibility.&lt;br&gt;
Not whether Ollama can answer a prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orchestration is where the maintenance cost shows up.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote admin is where the cute Mac mini story gets thin
&lt;/h2&gt;

&lt;p&gt;The internet makes headless Mac mini setups sound adorable.&lt;/p&gt;

&lt;p&gt;Tiny box.&lt;br&gt;
Silent.&lt;br&gt;
Efficient.&lt;br&gt;
Put it on a shelf and call it your AI server.&lt;/p&gt;

&lt;p&gt;Sure.&lt;/p&gt;

&lt;p&gt;But the minute you need reliable remote administration, you start touching way more of macOS than expected.&lt;/p&gt;

&lt;p&gt;SSH, power settings, permissions, launch agents vs launch daemons, login state, Full Disk Access edge cases.&lt;/p&gt;

&lt;p&gt;This is the part YouTube tutorials usually skip because it's less fun than benchmark screenshots.&lt;/p&gt;

&lt;p&gt;None of it is impossible.&lt;/p&gt;

&lt;p&gt;It's just not the clean little "local is simpler" story people like to tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was the Mac mini a bad idea?
&lt;/h2&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;It was a good idea for exactly the amount of complexity I had on day one.&lt;/p&gt;

&lt;p&gt;That's the part I wish more people said out loud.&lt;/p&gt;

&lt;p&gt;Local hosting is not bad.&lt;/p&gt;

&lt;p&gt;A Mac mini can absolutely win on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;privacy&lt;/li&gt;
&lt;li&gt;LAN latency&lt;/li&gt;
&lt;li&gt;fixed hardware cost&lt;/li&gt;
&lt;li&gt;fast iteration for one person&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ollama plus &lt;code&gt;launchd&lt;/code&gt; plus a couple helpers is a sane setup.&lt;/p&gt;

&lt;p&gt;The mistake is assuming a useful helper stays small.&lt;/p&gt;

&lt;p&gt;Useful helpers attract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more jobs&lt;/li&gt;
&lt;li&gt;more triggers&lt;/li&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;webhooks&lt;/li&gt;
&lt;li&gt;other users&lt;/li&gt;
&lt;li&gt;uptime expectations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that's when local orchestration stops being fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  My rule now
&lt;/h2&gt;

&lt;p&gt;If I'm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;testing prompts&lt;/li&gt;
&lt;li&gt;running a private summarizer&lt;/li&gt;
&lt;li&gt;building one-user automations&lt;/li&gt;
&lt;li&gt;experimenting with local models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I still like the Mac mini.&lt;/p&gt;

&lt;p&gt;If I'm depending on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;overlapping background executions&lt;/li&gt;
&lt;li&gt;reliable webhook handling&lt;/li&gt;
&lt;li&gt;worker behavior&lt;/li&gt;
&lt;li&gt;24/7 automations&lt;/li&gt;
&lt;li&gt;something I don't want to babysit at midnight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I stop pretending I'm "just running it locally."&lt;/p&gt;

&lt;p&gt;At that point I'm operating infrastructure, and I make decisions like it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part most local AI posts skip
&lt;/h2&gt;

&lt;p&gt;The breaking point wasn't model performance.&lt;/p&gt;

&lt;p&gt;It wasn't even Apple being weird.&lt;/p&gt;

&lt;p&gt;It was realizing that &lt;strong&gt;local-first breaks down around orchestration long before it breaks down around inference&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's why I think local model serving and production automation should be treated as separate decisions.&lt;/p&gt;

&lt;p&gt;You might absolutely want Ollama locally for privacy or speed.&lt;/p&gt;

&lt;p&gt;But if your agents, n8n workflows, or background automations are running constantly, the thing that hurts is usually not token generation.&lt;/p&gt;

&lt;p&gt;It's concurrency, retries, uptime, and cost predictability once usage stops being "a few tests" and turns into always-on work.&lt;/p&gt;

&lt;p&gt;That's also why flat-rate API infrastructure is more interesting than it used to be.&lt;/p&gt;

&lt;p&gt;If you're building agents or automations that hit LLMs all day, per-token pricing changes how you design everything. You start rationing calls, skipping useful steps, or watching cost dashboards instead of shipping.&lt;/p&gt;

&lt;p&gt;Standard Compute is interesting because it flips that tradeoff: OpenAI-compatible API, flat monthly pricing, and routing across models like GPT-5.4, Claude Opus 4.6, and Grok 4.20 without the usual per-token anxiety.&lt;/p&gt;

&lt;p&gt;That's a very different fit from a shelf Mac mini, but it's solving the exact problem week two exposed for me: once the workflow matters, predictability matters more than the demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical advice if you're building this right now
&lt;/h2&gt;

&lt;p&gt;If you're setting up a headless Mac mini for AI workflows, here's the short version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use Ollama for local experiments and private one-user tools.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;caffeinate&lt;/code&gt; and inspect &lt;code&gt;pmset&lt;/code&gt; before blaming your app.&lt;/li&gt;
&lt;li&gt;Move long-running jobs into &lt;code&gt;launchd&lt;/code&gt; early.&lt;/li&gt;
&lt;li&gt;Add logs from day one.&lt;/li&gt;
&lt;li&gt;Cap concurrency in n8n before the box teaches you why.&lt;/li&gt;
&lt;li&gt;Be honest about when "local" has become infrastructure.&lt;/li&gt;
&lt;li&gt;If your workflows run constantly, evaluate whether predictable API compute is actually simpler than self-hosting orchestration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole lesson.&lt;/p&gt;

&lt;p&gt;Week one was AI.&lt;/p&gt;

&lt;p&gt;Week two was operations.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>macos</category>
      <category>n8n</category>
      <category>devops</category>
    </item>
    <item>
      <title>I thought lead routing was a prompt problem until the AI assigned a California lead to nobody</title>
      <dc:creator>Lars Winstand</dc:creator>
      <pubDate>Thu, 17 Sep 2026 06:10:50 +0000</pubDate>
      <link>https://dev.to/lars_winstand/i-thought-lead-routing-was-a-prompt-problem-until-the-ai-assigned-a-california-lead-to-nobody-4kj1</link>
      <guid>https://dev.to/lars_winstand/i-thought-lead-routing-was-a-prompt-problem-until-the-ai-assigned-a-california-lead-to-nobody-4kj1</guid>
      <description>&lt;p&gt;I stopped trusting AI-first lead routing the day a valid inbound lead from California got assigned to… nobody.&lt;/p&gt;

&lt;p&gt;Not the wrong rep.&lt;/p&gt;

&lt;p&gt;Not the fallback SDR queue.&lt;/p&gt;

&lt;p&gt;Nobody.&lt;/p&gt;

&lt;p&gt;That’s the moment the whole architecture changed in my head.&lt;/p&gt;

&lt;p&gt;The prompt was fine. The model output looked reasonable. The bug was in the workflow logic around it.&lt;/p&gt;

&lt;p&gt;If you’re building CRM automation in n8n, Make, Zapier, OpenClaw, or a custom agent stack, this is the part that matters: LLMs are good at suggesting. They are bad candidates for being the final source of truth on ownership.&lt;/p&gt;

&lt;p&gt;The safe pattern is boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Let GPT-5 or Claude propose a segment or owner&lt;/li&gt;
&lt;li&gt;Validate with deterministic routing rules&lt;/li&gt;
&lt;li&gt;Send conflicts and low-confidence cases to a human queue&lt;/li&gt;
&lt;li&gt;Only then write the final owner to HubSpot or Salesforce&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sounds less exciting than “AI handles lead routing end-to-end.”&lt;/p&gt;

&lt;p&gt;It also works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bug wasn’t the prompt
&lt;/h2&gt;

&lt;p&gt;The original workflow looked clean on a whiteboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read the lead&lt;/li&gt;
&lt;li&gt;infer segment&lt;/li&gt;
&lt;li&gt;assign owner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Modern. Minimal. Very demo-friendly.&lt;/p&gt;

&lt;p&gt;But production systems don’t fail on whiteboards.&lt;/p&gt;

&lt;p&gt;Here’s what actually happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the model suggested an owner based on company description and geography&lt;/li&gt;
&lt;li&gt;one branch checked territory&lt;/li&gt;
&lt;li&gt;another branch excluded house accounts&lt;/li&gt;
&lt;li&gt;another checked existing ownership rules&lt;/li&gt;
&lt;li&gt;two conditions overlapped&lt;/li&gt;
&lt;li&gt;one validation failed&lt;/li&gt;
&lt;li&gt;nothing handled the collision properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the record fell through the cracks.&lt;/p&gt;

&lt;p&gt;That’s not a prompt failure.&lt;/p&gt;

&lt;p&gt;That’s a control-plane failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLMs feel better at routing than they actually are
&lt;/h2&gt;

&lt;p&gt;Ask GPT-5, Claude Opus 4.6, or a solid open model to read a lead and suggest an owner.&lt;/p&gt;

&lt;p&gt;You’ll usually get something plausible.&lt;/p&gt;

&lt;p&gt;That’s the trap.&lt;/p&gt;

&lt;p&gt;Plausible is not governable.&lt;/p&gt;

&lt;p&gt;Real lead routing has rules like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;named accounts always stay with the account executive&lt;/li&gt;
&lt;li&gt;EMEA enterprise goes to one team except strategic partners&lt;/li&gt;
&lt;li&gt;California startup leads route one way unless they came from a partner form&lt;/li&gt;
&lt;li&gt;existing open opportunities override fresh inbound logic&lt;/li&gt;
&lt;li&gt;low-confidence enrichment should never trigger auto-assignment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An LLM can summarize those rules.&lt;/p&gt;

&lt;p&gt;It should not be the final authority on those rules.&lt;/p&gt;

&lt;p&gt;If it is, you’re going to spend Friday explaining weird ownership changes to RevOps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture I trust
&lt;/h2&gt;

&lt;p&gt;This is the split I’d recommend for most teams:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Best owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Normalize messy form input&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summarize enrichment&lt;/td&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infer probable segment from incomplete data&lt;/td&gt;
&lt;td&gt;LLM proposal only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enforce named account exclusions&lt;/td&gt;
&lt;td&gt;Deterministic rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enforce territory and geography&lt;/td&gt;
&lt;td&gt;Deterministic rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolve conflicts&lt;/td&gt;
&lt;td&gt;Human review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write final owner to HubSpot or Salesforce&lt;/td&gt;
&lt;td&gt;Workflow after validation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That split keeps the model in the fuzzy-data lane and keeps the workflow engine in the policy lane.&lt;/p&gt;

&lt;p&gt;If your routing is already based on clean fields like country, state, company size, or named account lists, you may not need an LLM at all.&lt;/p&gt;

&lt;p&gt;Seriously.&lt;/p&gt;

&lt;p&gt;A lot of teams add AI where a Switch node would have been enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  n8n is a good example of where routing gets weird
&lt;/h2&gt;

&lt;p&gt;n8n makes the mechanics visible, which is useful.&lt;/p&gt;

&lt;p&gt;The Switch node has a few settings that matter a lot in lead routing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rules vs Expression mode&lt;/li&gt;
&lt;li&gt;Fallback Output behavior&lt;/li&gt;
&lt;li&gt;send to first matching output vs send to all matching outputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is not cosmetic.&lt;/p&gt;

&lt;p&gt;If your routing rules overlap, these two settings create very different behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;first matching output: quietly picks a winner&lt;/li&gt;
&lt;li&gt;all matching outputs: exposes that your rules collide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For lead routing, I want collisions exposed.&lt;/p&gt;

&lt;p&gt;If two territory rules match the same lead, that should go to review. I do not want the workflow silently pretending the first branch was obviously correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example: bad routing shape
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pseudocode&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US Team&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;company_size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;West SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;named_account&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;account_executive&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;Looks harmless.&lt;/p&gt;

&lt;p&gt;But if multiple branches run and overwrite &lt;code&gt;owner&lt;/code&gt;, your audit trail becomes nonsense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Better pattern: propose, validate, resolve
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aiSuggestion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;proposed_owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;West SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;proposed_segment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California Startup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HQ in San Francisco, 42 employees, SaaS category&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;namedAccount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;named_account&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hasExistingOppOwner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open_opportunity_owner&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;matchesWestSMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company_size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;matchesPartnerException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;partner_form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namedAccount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_executive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;named_account_rule&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasExistingOppOwner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open_opportunity_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;existing_opp_owner_rule&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiSuggestion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendToReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low_confidence_ai_suggestion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matchesWestSMB&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matchesPartnerException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;West SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;territory_rule_ca_smb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendToReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no_clear_owner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That version is much less magical.&lt;/p&gt;

&lt;p&gt;It is also much easier to debug three weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  HubSpot already hints at the right answer
&lt;/h2&gt;

&lt;p&gt;HubSpot talks a lot now about AI-assisted workflow creation.&lt;/p&gt;

&lt;p&gt;Fine. Useful, even.&lt;/p&gt;

&lt;p&gt;But the part that actually keeps lead routing safe is still the old-school automation machinery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enrollment triggers&lt;/li&gt;
&lt;li&gt;re-enrollment controls&lt;/li&gt;
&lt;li&gt;permissions&lt;/li&gt;
&lt;li&gt;action history&lt;/li&gt;
&lt;li&gt;publishing controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the real story.&lt;/p&gt;

&lt;p&gt;The safe pattern inside HubSpot is not “AI decides owner.”&lt;/p&gt;

&lt;p&gt;It’s “AI helps annotate the lead, then governed workflow logic decides owner.”&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human fallback is not a cop-out
&lt;/h2&gt;

&lt;p&gt;It’s the correct design.&lt;/p&gt;

&lt;p&gt;n8n has documented a human fallback pattern for AI workflows, and it maps perfectly to lead routing.&lt;/p&gt;

&lt;p&gt;Use this flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AI proposes owner, segment, or missing structured fields&lt;/li&gt;
&lt;li&gt;deterministic rules validate territory, exclusions, and account ownership&lt;/li&gt;
&lt;li&gt;low-confidence or conflicting cases go to Slack&lt;/li&gt;
&lt;li&gt;human approves final owner&lt;/li&gt;
&lt;li&gt;workflow writes the owner back to HubSpot or Salesforce&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Cases I would always send to review
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;enrichment confidence below threshold&lt;/li&gt;
&lt;li&gt;two territory rules match the same lead&lt;/li&gt;
&lt;li&gt;named account conflicts with geography routing&lt;/li&gt;
&lt;li&gt;existing opportunity owner conflicts with inbound owner&lt;/li&gt;
&lt;li&gt;the model inferred a critical field instead of reading it directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s not anti-AI.&lt;/p&gt;

&lt;p&gt;That’s just adult supervision.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenClaw makes action easier, which raises the stakes
&lt;/h2&gt;

&lt;p&gt;Agent tooling is getting fast.&lt;/p&gt;

&lt;p&gt;OpenClaw, for example, is easy to stand up:&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;-fsSL&lt;/span&gt; https://openclaw.ai/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; openclaw
openclaw onboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s great if you want to get an agent running quickly.&lt;/p&gt;

&lt;p&gt;But easy action is not the same thing as governed action.&lt;/p&gt;

&lt;p&gt;This is the pattern I keep seeing across agent stacks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;agents are getting better at doing things&lt;/li&gt;
&lt;li&gt;teams are still bad at explaining why those things happened&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For CRM ownership, explanation is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the assignment auditable
&lt;/h2&gt;

&lt;p&gt;If you only take one thing from this post, make it this checklist.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Make AI output advisory
&lt;/h3&gt;

&lt;p&gt;Have GPT-5, Claude, Qwen, or Llama return a suggestion and confidence score.&lt;/p&gt;

&lt;p&gt;Not a final writeback.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Keep hard rules outside the prompt
&lt;/h3&gt;

&lt;p&gt;Named accounts, do-not-route lists, existing opportunity ownership, and territory exceptions should live in workflow logic or application code.&lt;/p&gt;

&lt;p&gt;Not buried in prompt prose.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Design for collisions on purpose
&lt;/h3&gt;

&lt;p&gt;If overlapping rules are possible, treat that as an exception path.&lt;/p&gt;

&lt;p&gt;Don’t hide it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keep an exception queue
&lt;/h3&gt;

&lt;p&gt;Slack works.&lt;/p&gt;

&lt;p&gt;So does Jira, a HubSpot queue, or an internal review UI.&lt;/p&gt;

&lt;p&gt;Uncertain records need a place to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Store the reason for the final assignment
&lt;/h3&gt;

&lt;p&gt;Persist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI suggestion&lt;/li&gt;
&lt;li&gt;confidence score&lt;/li&gt;
&lt;li&gt;deterministic rule that won&lt;/li&gt;
&lt;li&gt;whether a human approved it&lt;/li&gt;
&lt;li&gt;timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If someone asks why a rep got a lead, “the agent decided” is not an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal implementation shape
&lt;/h2&gt;

&lt;p&gt;Here’s a simple pattern using an OpenAI-compatible client plus deterministic validation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_BASE_URL&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;suggestRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
Suggest a sales segment and owner for this lead.
Return JSON with: proposed_owner, proposed_segment, confidence, reason.
Lead: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-5.4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateRouting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;suggestion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;named_account&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_executive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;named_account_rule&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open_opportunity_owner&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open_opportunity_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;existing_opp_owner_rule&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;suggestion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low_confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company_size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;West SMB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;territory_rule_ca_smb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&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="na"&gt;review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no_matching_rule&lt;/span&gt;&lt;span class="dl"&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;That’s the shape.&lt;/p&gt;

&lt;p&gt;AI for interpretation.&lt;/p&gt;

&lt;p&gt;Code for policy.&lt;/p&gt;

&lt;p&gt;Humans for ambiguity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost problem shows up faster than people expect
&lt;/h2&gt;

&lt;p&gt;There’s another issue teams hit once they start doing this at volume: cost.&lt;/p&gt;

&lt;p&gt;Fallback-heavy workflows are expensive to iterate on when every test run is billed per token.&lt;/p&gt;

&lt;p&gt;Every one of these adds cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;branch testing&lt;/li&gt;
&lt;li&gt;confidence threshold tuning&lt;/li&gt;
&lt;li&gt;comparing GPT-5 vs Claude on segmentation&lt;/li&gt;
&lt;li&gt;sending borderline cases through multiple models&lt;/li&gt;
&lt;li&gt;testing human-review thresholds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you’re building AI automations in n8n, Make, Zapier, OpenClaw, or custom OpenAI-compatible stacks, pricing affects architecture.&lt;/p&gt;

&lt;p&gt;If every experiment feels metered, teams test less.&lt;/p&gt;

&lt;p&gt;That usually means worse routing logic in production.&lt;/p&gt;

&lt;p&gt;This is exactly why flat-rate AI access is useful for workflow builders. With Standard Compute, you can keep the OpenAI-compatible API shape, route across models like GPT-5.4, Claude Opus 4.6, and Grok 4.20, and iterate on real automation logic without treating every workflow run like a taximeter.&lt;/p&gt;

&lt;p&gt;That matters a lot when your workflow includes retries, fallbacks, and human-review loops.&lt;/p&gt;

&lt;h2&gt;
  
  
  My opinionated take
&lt;/h2&gt;

&lt;p&gt;If an LLM is directly assigning owners in production without deterministic validation, you have not automated lead routing.&lt;/p&gt;

&lt;p&gt;You have automated future arguments.&lt;/p&gt;

&lt;p&gt;The easy part was getting Claude or GPT-5 to output a rep name.&lt;/p&gt;

&lt;p&gt;That demo works in five minutes.&lt;/p&gt;

&lt;p&gt;The hard part is building a system where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ownership rules are explicit&lt;/li&gt;
&lt;li&gt;confidence is checked&lt;/li&gt;
&lt;li&gt;collisions are visible&lt;/li&gt;
&lt;li&gt;exceptions stop for review&lt;/li&gt;
&lt;li&gt;every assignment is explainable later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the version that survives contact with actual sales teams.&lt;/p&gt;

&lt;p&gt;And if you’re doing it at scale, predictable AI cost matters almost as much as correct logic.&lt;/p&gt;

&lt;p&gt;Because a workflow you can’t afford to test properly is not production-ready either.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>n8n</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
