<?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: Frihet</title>
    <description>The latest articles on DEV Community by Frihet (frihet).</description>
    <link>https://dev.to/frihet</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%2Forganization%2Fprofile_image%2F12873%2F4694c98b-20b3-4f66-a0bd-0d9b43591d23.jpeg</url>
      <title>DEV Community: Frihet</title>
      <link>https://dev.to/frihet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frihet"/>
    <language>en</language>
    <item>
      <title>Designing MCP tools an agent won't misuse</title>
      <dc:creator>berthelius</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:00:11 +0000</pubDate>
      <link>https://dev.to/frihet/designing-mcp-tools-an-agent-wont-misuse-1ah1</link>
      <guid>https://dev.to/frihet/designing-mcp-tools-an-agent-wont-misuse-1ah1</guid>
      <description>&lt;p&gt;The first article in this series made a pair of claims. One: an AI-native product is an MCP server that lets an agent &lt;em&gt;do&lt;/em&gt; things, not a chat widget that talks about them. Two: every write an agent can invoke needs idempotency and typed, recoverable errors, because agents retry and fan out by default.&lt;/p&gt;

&lt;p&gt;Both took typed schemas for granted. Of course you validate your inputs — &lt;code&gt;zod&lt;/code&gt; on the way in, reject the malformed call. That's table stakes. This article is about the failure mode that survives input validation: &lt;strong&gt;misuse&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An agent almost never sends you malformed JSON. It sends you &lt;em&gt;well-formed wrong&lt;/em&gt;. The payload passes every schema check and still does the wrong thing, because a language model is a probabilistic caller reasoning from your tool descriptions, not a developer who read your docs. Typing the inputs stops garbage. It does nothing about a valid call that shouldn't have been made. Designing against &lt;em&gt;that&lt;/em&gt; is a different job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways a well-typed call still goes wrong
&lt;/h2&gt;

&lt;p&gt;Watch a model drive a real tool catalog and the same four failures recur:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrong tool.&lt;/strong&gt; You expose &lt;code&gt;update_invoice&lt;/code&gt; and &lt;code&gt;send_invoice&lt;/code&gt;. The agent means "send" but the descriptions overlap, so it calls "update" with a &lt;code&gt;status&lt;/code&gt; field and assumes that mailed the PDF. Every argument is valid. Nothing was sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valid-but-wrong argument.&lt;/strong&gt; A field takes a free-text &lt;code&gt;region&lt;/code&gt; string. The model writes &lt;code&gt;"Canary Islands"&lt;/code&gt;, &lt;code&gt;"canarias"&lt;/code&gt;, &lt;code&gt;"ES-CN"&lt;/code&gt;, or &lt;code&gt;"islas canarias"&lt;/code&gt; on different runs. All are strings. Your tax engine understands exactly one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong order / missing precondition.&lt;/strong&gt; The agent issues a credit note against an invoice that was never finalized, or bills a client it hasn't created. The call is shaped correctly; the world isn't in the state the call assumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't recover.&lt;/strong&gt; Something fails and the error is a stringified &lt;code&gt;500&lt;/code&gt;. The model can't tell "back off and retry" from "this will never work," so it either gives up or hammers the endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these is an input-validation bug. All four are &lt;em&gt;interface design&lt;/em&gt; bugs. The good news: the same MCP surface that lets an agent operate your domain also gives you the levers to prevent them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lever 1 — constrain the input space, don't just type it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;region: z.string()&lt;/code&gt; is typed. It is also an open door: infinitely many valid strings, one of which your server accepts. The fix is old and boring — make illegal states unrepresentable — and it maps directly onto tool schemas. Replace the open type with a closed one, so the model chooses from a menu instead of inventing a value.&lt;/p&gt;

&lt;p&gt;Here's the real shape from the Frihet MCP server's invoice tools. The fiscal zone that decides whether a line carries mainland IVA, Canary Islands IGIC, or an exemption is not a string — it's an enum, and the operation type is a two-value enum:&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;// src/tools/invoices.ts — invoiceFiscalFields: fiscal zone drives IVA vs IGIC vs exempt, never a free string&lt;/span&gt;
&lt;span class="nx"&gt;clientLocation&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;peninsula&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;canarias&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;ceuta_melilla&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;eu&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;world&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;optional&lt;/span&gt;&lt;span class="p"&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;Fiscal zone driving IVA vs IGIC vs exempt / Zona fiscal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="nx"&gt;operationType&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;service&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;goods&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;optional&lt;/span&gt;&lt;span class="p"&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;Operation type (service or goods) / Tipo de operacion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="nx"&gt;irpfRate&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;number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&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;IRPF withholding % (retencion autonomo ES) / Retencion IRPF %&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;Three constraints, three classes of misuse closed off. The enum collapses &lt;code&gt;"Canary Islands"&lt;/code&gt; and its four spellings into one legal token the model can't get wrong — and the enum values are visible to the model at tool-discovery time, so it picks rather than guesses. The &lt;code&gt;.min(0).max(100)&lt;/code&gt; on a percentage means the agent can't submit a 150% withholding because it misread a prompt. And a two-value &lt;code&gt;operationType&lt;/code&gt; enum is a decision the model makes explicitly instead of leaving your server to infer intent.&lt;/p&gt;

&lt;p&gt;The rule of thumb: for every input field, ask "how many values would I accept here, and how many does the domain actually allow?" When those numbers differ, the gap is where an agent will eventually land. Close it with an enum, a numeric bound, a length cap, or a discriminated union — in the schema, where the model can see it, not in a &lt;code&gt;422&lt;/code&gt; it discovers after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lever 2 — the description and the annotations are part of the contract
&lt;/h2&gt;

&lt;p&gt;A tool's &lt;code&gt;description&lt;/code&gt; is not a comment. It's the only thing the model reads to decide &lt;em&gt;which&lt;/em&gt; tool to call. Overlapping, vague descriptions are how you get the wrong-tool failure. Two tools whose descriptions could each plausibly answer "send this invoice" is a routing bug you shipped, and no amount of input typing fixes it. Descriptions should be disjoint and imperative: name the one job each tool does and, where useful, name what it does &lt;em&gt;not&lt;/em&gt; do.&lt;/p&gt;

&lt;p&gt;MCP gives you a second, machine-readable channel for this: &lt;strong&gt;tool annotations&lt;/strong&gt;. They're advisory hints attached to each registration — not enforcement, but signals the host can act on. The Frihet server tags every tool with one of four constants:&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;// src/tools/shared.ts — safety annotations, applied per tool&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;READ_ONLY_ANNOTATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&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;destructiveHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;idempotentHint&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;openWorldHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREATE_ANNOTATIONS&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructiveHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;idempotentHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;openWorldHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UPDATE_ANNOTATIONS&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructiveHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;idempotentHint&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;openWorldHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DELETE_ANNOTATIONS&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructiveHint&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;idempotentHint&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;openWorldHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read what those flags encode. A read tool is safe and idempotent — a host can auto-approve it. A &lt;code&gt;create&lt;/code&gt; tool is &lt;code&gt;idempotentHint: false&lt;/code&gt;: calling it twice is &lt;em&gt;not&lt;/em&gt; safe, which is precisely the signal that says "this write needs an idempotency key". A &lt;code&gt;delete&lt;/code&gt; tool is &lt;code&gt;destructiveHint: true&lt;/code&gt; — the flag a well-behaved client uses to require human confirmation before letting an agent run it unattended.&lt;/p&gt;

&lt;p&gt;These are hints, and the spec is explicit that a client shouldn't make security decisions on annotations alone — real enforcement still lives server-side in auth and validation. But as a &lt;em&gt;design&lt;/em&gt; tool they're doing something important: they let the host gate the agent's autonomy per tool instead of all-or-nothing. Auto-run the reads, checkpoint the deletes. Misuse-by-blast-radius, contained by metadata you declared once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lever 3 — type the output and the errors, so recovery is deterministic
&lt;/h2&gt;

&lt;p&gt;The last two failure modes — wrong order and can't-recover — are both about what the model learns &lt;em&gt;after&lt;/em&gt; the call.&lt;/p&gt;

&lt;p&gt;Typed output is what lets an agent chain calls without hallucinating the shape of your data. On the Frihet server every tool returns structured output via an &lt;code&gt;outputSchema&lt;/code&gt;, and list tools return real pagination — &lt;code&gt;{ data, total, limit, offset }&lt;/code&gt; — so an agent pages deterministically instead of guessing whether it saw everything. Prose replies force the model to parse, and parsing is where it invents fields that were never there.&lt;/p&gt;

&lt;p&gt;Errors are the other half, and they're where "can't recover" is won or lost. A stack trace tells a model nothing. A stable, typed error tells it what went wrong &lt;em&gt;and what to do next&lt;/em&gt;. The server maps every failure onto one error class with a machine-readable code:&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;// src/client.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FrihetApiError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;statusCode&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;errorCode&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="c1"&gt;// stable string, e.g. "rate_limit_exceeded"&lt;/span&gt;
    &lt;span class="nx"&gt;message&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="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;errorCode&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;The agent branches on &lt;code&gt;errorCode&lt;/code&gt;, never on a stringified status. A &lt;code&gt;rate_limit_exceeded&lt;/code&gt; means back off — and the server already retries &lt;code&gt;429&lt;/code&gt;s with exponential backoff before it ever surfaces one, honoring the &lt;code&gt;Retry-After&lt;/code&gt; header. A &lt;code&gt;request_timeout&lt;/code&gt; means the write &lt;em&gt;may or may not&lt;/em&gt; have landed, so retry with the same idempotency key. A validation code means the payload is wrong; retrying it unchanged just burns the 100-requests-per-minute budget against a wall. (The retry taxonomy itself — retryable vs terminal, backoff with jitter — is a subject of its own; the point here is that the &lt;em&gt;code&lt;/em&gt; is the interface that makes those decisions possible.)&lt;/p&gt;

&lt;p&gt;Same idea for the wrong-order failure: return a precise, named error the agent can act on — "finalize the invoice before issuing a credit note" — instead of a generic 400. A typed error is an instruction. A 500 is a dead end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design review that catches misuse
&lt;/h2&gt;

&lt;p&gt;Before you register a tool, run it past four questions. They map one-to-one onto the failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong tool?&lt;/strong&gt; Is this tool's description disjoint from every other tool's, and does it name the one job it does? Could a model reasonably confuse it with a neighbor?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Valid-but-wrong argument?&lt;/strong&gt; For every field, does the schema allow only what the domain allows — enums over free strings, bounded numbers, discriminated unions — with the legal values visible at discovery time?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autonomy gating?&lt;/strong&gt; Are read / create / update / delete annotated so a host can auto-approve the safe ones and checkpoint the destructive ones? Does the &lt;code&gt;create&lt;/code&gt; tool that isn't idempotent say so?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recoverable?&lt;/strong&gt; Does every failure return a stable &lt;code&gt;errorCode&lt;/code&gt; and structured output the agent can branch on and page through — never a stringified 500?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typed inputs keep malformed calls out. Designing the surface — constrained schemas, disjoint descriptions, safety annotations, typed errors — keeps &lt;em&gt;wrong&lt;/em&gt; calls out. The first is validation. The second is the actual interface an agent operates, and it's the one that decides whether 157 tools are 157 capabilities or 157 ways to get it subtly wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Earlier in this series: &lt;a href="https://dev.to/brthls/ai-native-isnt-a-chatbot-its-an-mcp-server-2ipg"&gt;why AI-native means an MCP server, not a chatbot&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Make agent-callable writes idempotent, or lose data</title>
      <dc:creator>berthelius</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:00:11 +0000</pubDate>
      <link>https://dev.to/frihet/make-agent-callable-writes-idempotent-or-lose-data-2n5m</link>
      <guid>https://dev.to/frihet/make-agent-callable-writes-idempotent-or-lose-data-2n5m</guid>
      <description>&lt;p&gt;An agent-native product isn't a chatbot bolted onto a CRUD app — it's an MCP server that lets an agent &lt;em&gt;do&lt;/em&gt; things. Read invoices, create expenses, mark a client overdue. The demo is easy. The part that decides whether the thing survives contact with real traffic is the layer nobody screenshots: what happens when a write is sent twice.&lt;/p&gt;

&lt;p&gt;This is the unglamorous half of agent-native engineering. Every write operation an agent can invoke needs idempotency, safe retries, and typed recoverable errors — not as a nice-to-have, but as the difference between a product and a liability. Here's why, and how to build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents turn "at-least-once" from a footnote into a daily event
&lt;/h2&gt;

&lt;p&gt;Distributed systems people have known forever that networks give you at-least-once delivery, not exactly-once. A request goes out, the response gets lost on the way back, the caller doesn't know if the write landed, so it retries. With a human clicking a button, this is rare enough to hand-wave.&lt;/p&gt;

&lt;p&gt;Agents remove the hand-wave. Three things about how agents call tools push duplicate writes from "edge case" to "Tuesday":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;They retry automatically.&lt;/strong&gt; A well-behaved MCP client backs off and retries on &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;5xx&lt;/code&gt;. The Frihet MCP server (&lt;a href="https://github.com/Frihet-io/frihet-mcp" rel="noopener noreferrer"&gt;github.com/Frihet-io/frihet-mcp&lt;/a&gt;) enforces 100 requests per minute per &lt;code&gt;fri_&lt;/code&gt; key, and its own client retries with exponential backoff when it hits that ceiling. A retry after a &lt;code&gt;429&lt;/code&gt; is &lt;em&gt;supposed&lt;/em&gt; to happen — which means every write behind that endpoint has to survive being delivered more than once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They time out and resume.&lt;/strong&gt; An agent loop hits a wall-clock limit mid-tool-call, the session gets resumed, and the planner — having never seen a result — reasonably decides to "create the invoice" again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They fan out.&lt;/strong&gt; Give a model a goal ("bill everyone for last month") and it will happily issue parallel tool calls. Two of them racing on the same logical write is now a concurrency problem, not a theoretical one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;MCP itself does not save you here. A tool call is just RPC over JSON — the protocol says nothing about whether invoking &lt;code&gt;create_invoice&lt;/code&gt; twice creates one invoice or two. That contract is entirely yours to define on the server. If you don't define it, you've defined it as "two."&lt;/p&gt;

&lt;p&gt;For a read tool, a duplicate is free. For a write tool in a fiscal domain — an ERP that prepares VeriFactu records, IVA and IRPF filings — a duplicate is a phantom invoice in someone's tax quarter. The blast radius is exactly why the boring layer matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency keys: the client names the request, once
&lt;/h2&gt;

&lt;p&gt;The fix is to let the caller assign a stable identity to a &lt;em&gt;logical&lt;/em&gt; operation, independent of how many times it's physically delivered. That's an idempotency key: a client-generated token (usually a UUID) that stays the same across retries of the same intended write, and changes for a genuinely new one.&lt;/p&gt;

&lt;p&gt;The rule for the client is simple: &lt;strong&gt;generate the key once, at the point of intent, and reuse it for every retry of that same intent.&lt;/strong&gt; If the agent decides to bill a client, it mints one key and carries it through all four delivery attempts. When it decides to bill a &lt;em&gt;different&lt;/em&gt; client, it mints a new one.&lt;/p&gt;

&lt;p&gt;The server's job is to make the key mean something: the &lt;em&gt;first&lt;/em&gt; time it sees a key, do the work and remember the result; every subsequent time, return the remembered result without touching the database again.&lt;/p&gt;

&lt;p&gt;The subtlety that trips people up is atomicity. "Check if the key exists, then insert it" is a race — two concurrent retries both read "not found" and both do the work. The claim has to be a single atomic operation, which in Postgres is an &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; and in most KV stores a compare-and-set.&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;createHash&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;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WriteContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;accountId&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="c1"&gt;// scope keys per tenant — never globally&lt;/span&gt;
  &lt;span class="nl"&gt;toolName&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="c1"&gt;// e.g. "create_invoice"&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&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="c1"&gt;// client-supplied, stable across retries&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;runOnce&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;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WriteContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;work&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="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;const&lt;/span&gt; &lt;span class="nx"&gt;slot&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolName&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;ctx&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="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;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&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;update&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;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Atomic claim: only the FIRST caller inserts the row.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// INSERT ... ON CONFLICT DO NOTHING&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Key already seen. Same body  -&amp;gt; return the stored result.&lt;/span&gt;
    &lt;span class="c1"&gt;// Different body -&amp;gt; the client reused a key for a new request. Reject.&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&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;ToolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_key_reused&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;retryable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Idempotency key was reused with a different payload.&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;awaitResult&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;slot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// return the SAME result, never re-run&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;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;work&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// release the slot so a genuine retry can proceed&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;Three things this snippet gets right that naive versions miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The key is scoped per tenant.&lt;/strong&gt; Two accounts can generate the same UUID; without &lt;code&gt;accountId&lt;/code&gt; in the slot, one customer's retry could return another's invoice. Scope aggressively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The payload is fingerprinted.&lt;/strong&gt; Reusing a key with a &lt;em&gt;different&lt;/em&gt; body is almost always a client bug. Returning the old result silently would hide it; creating a new record would defeat the point. Rejecting it with a typed error is the honest answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stored result is returned verbatim.&lt;/strong&gt; The whole contract is that the second caller gets &lt;em&gt;exactly&lt;/em&gt; what the first caller got — same invoice id, same numbers — not a fresh write that happens to look similar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How long do you keep the record? Long enough to outlive any retry storm — hours to a day is typical, and it's a deliberate trade-off, not a default. Too short and a slow retry slips through the window and double-writes; too long and you're storing every request body forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe retries need a taxonomy, not a &lt;code&gt;try/catch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Idempotency makes retrying &lt;em&gt;safe&lt;/em&gt;. It doesn't tell you &lt;em&gt;when&lt;/em&gt; to retry. That decision depends entirely on what went wrong, and "an error happened" is not enough information to make it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;503&lt;/code&gt; is transient — back off and retry.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;422 validation_failed&lt;/code&gt; is permanent — retrying the same bad payload a thousand times just burns your rate limit against a wall.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;504&lt;/code&gt; gateway timeout is the genuinely hard one: the write &lt;em&gt;may or may not&lt;/em&gt; have landed. This is the exact case idempotency was built for. You retry with the &lt;em&gt;same&lt;/em&gt; key, and the server either finishes the original work or hands back the result it already produced. Without the key, a retry here is how you double-bill someone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the agent to make this call, the error has to be machine-readable. A stringified &lt;code&gt;500 Internal Server Error&lt;/code&gt; forces the model to guess from prose, and models guess wrong. Ship a typed error taxonomy instead: a stable &lt;code&gt;code&lt;/code&gt;, an explicit &lt;code&gt;retryable&lt;/code&gt; boolean, and a hint for how long to wait.&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;ErrorCode&lt;/span&gt; &lt;span class="o"&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;rate_limited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;// 429 — back off, then retry&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upstream_timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;// 504 — retry with the SAME idempotency key&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validation_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;// 422 — do NOT retry; the payload is wrong&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;insufficient_funds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;// 402 — do NOT retry; surface to the user&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_key_reused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="c1"&gt;//     — client bug; do NOT retry&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;conflict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// 409 — a concurrent write won; re-read, then decide&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ErrorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;retryable&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="nl"&gt;message&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;retryAfterMs&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="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;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meta&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The client/agent reads `retryable` — it never parses a stringified 500.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isRetryable&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="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;ToolError&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ToolError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retryable&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;Pair the taxonomy with exponential backoff &lt;em&gt;and jitter&lt;/em&gt;. Backoff without jitter means every one of your fanned-out tool calls that got rate-limited retries at the same instant — a synchronized stampede that trips the limit again. Randomizing the delay spreads the herd out. And cap the attempts: retryable does not mean retry forever.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;retryable&lt;/code&gt; lives on the server's side of the contract but is &lt;em&gt;consumed&lt;/em&gt; by the client. That's the point — the server knows whether a write is safe to repeat, so it says so explicitly instead of making every caller reverse-engineer the answer from an HTTP status and a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the whole game for agent-native products
&lt;/h2&gt;

&lt;p&gt;A demo proves an agent &lt;em&gt;can&lt;/em&gt; create an invoice. Production asks a harder question: when the same instruction gets delivered twice — because a network blipped, a session resumed, or a &lt;code&gt;429&lt;/code&gt; triggered the retry the client was designed to perform — does your customer end up with one invoice or two?&lt;/p&gt;

&lt;p&gt;Consider the surface area. A server like Frihet exposes 157 tools over &lt;code&gt;mcp.frihet.io&lt;/code&gt;, a large share of them writes into an ERP that generates real tax records. Every one of those write tools is a place where at-least-once delivery meets a fiscal side effect. Idempotency keys, atomic server-side dedup, and typed recoverable errors are what turn that surface from a duplicate-data generator into something an agent can hammer safely at 100 requests a minute. That contract is the argument of this article: it is what every server on the other end of an agent owes its callers. It is also the least glamorous work in the codebase and the work that determines whether the product is trustworthy.&lt;/p&gt;

&lt;p&gt;The industry spent a decade learning this for payments — it's why Stripe made idempotency keys a first-class header. Agents make the lesson urgent everywhere at once, because agents retry, resume, and fan out by default. If you're building anything an agent can write to, treat this as the foundation, not the polish. The chatbot is the part users see. This is the part that keeps their data correct.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next up: designing tool schemas an agent won't misuse — types, enums, and error contracts as the real interface.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Zero Admin: The Complete Stack to Run Your Business on Autopilot</title>
      <dc:creator>berthelius</dc:creator>
      <pubDate>Sun, 05 Apr 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/frihet/zero-admin-the-complete-stack-to-run-your-business-on-autopilot-3mbl</link>
      <guid>https://dev.to/frihet/zero-admin-the-complete-stack-to-run-your-business-on-autopilot-3mbl</guid>
      <description>&lt;p&gt;Picture this: a client accepts your quote on a Tuesday at 11:00 AM. At 11:01, the invoice is in their inbox. Thursday, they pay. The payment reconciles against the invoice automatically. At the end of the quarter, your sales tax is calculated, your tax forms are pre-filled, and you have not opened a spreadsheet in three months.&lt;/p&gt;

&lt;p&gt;This is not a fantasy. It is a stack you can build today, in under an hour.&lt;/p&gt;

&lt;p&gt;This guide is a complete blueprint. Not "which tool to use" but the entire flow: from the first client handshake to your quarterly tax filing. Step by step, no filler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Zero Admin actually means (and what it does not)
&lt;/h2&gt;

&lt;p&gt;Zero Admin is not zero effort. It is zero &lt;em&gt;unnecessary&lt;/em&gt; effort.&lt;/p&gt;

&lt;p&gt;The distinction matters. Every business has two kinds of administrative work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mechanical work:&lt;/strong&gt; copying data between platforms, categorizing expenses by hand, matching bank transactions to invoices, calculating quarterly tax, filling out tax forms. This work does not require human intelligence. It requires time. And it steals 10+ hours from your week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intellectual work:&lt;/strong&gt; approving an unusual expense, deciding whether a project is profitable, negotiating payment terms with a client, choosing between investing and saving. This work actually needs you.&lt;/p&gt;

&lt;p&gt;:::quote&lt;br&gt;
Zero Admin = the system handles data, calculations, filing, and repetition. You handle relationships, strategy, and decisions.&lt;br&gt;
:::&lt;/p&gt;

&lt;p&gt;The goal is not to eliminate management. It is to eliminate the management that adds nothing. Let the back office run as a silent system in the background while you do the work that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete flow, step by step
&lt;/h2&gt;

&lt;p&gt;This is what happens when the right pieces talk to each other. An end-to-end flow, from the moment a client says "yes" to the moment the tax authority has its numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Client accepts → Invoice sent
&lt;/h3&gt;

&lt;p&gt;The client accepts your quote. Frihet converts that quote into an invoice with one click. Same data, same line items, same client. Nothing re-typed.&lt;/p&gt;

&lt;p&gt;The invoice is sent automatically via email with a payment link (if you have Stripe Connect active). The client receives a professional PDF and can pay directly from the email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens without your intervention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential numbering assigned (series + atomic counter)&lt;/li&gt;
&lt;li&gt;SHA-256 hash generated (compliance chain)&lt;/li&gt;
&lt;li&gt;PDF created with your branding&lt;/li&gt;
&lt;li&gt;Email sent to the client&lt;/li&gt;
&lt;li&gt;Status: "Sent" on your dashboard&lt;/li&gt;
&lt;li&gt;Due date calculated from your defaults&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; one click to confirm the accepted quote becomes an invoice. If your quotes are already solid, it is a 2-second gesture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Payment received → Revenue recognized
&lt;/h3&gt;

&lt;p&gt;The client pays. Stripe processes the charge. Frihet receives a webhook notification and marks the invoice as paid. Automatically.&lt;/p&gt;

&lt;p&gt;If you do not use Stripe, bank sync imports the transaction and Frihet matches the amount to the outstanding invoice. Same result, different route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens without your intervention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payment reconciled against the corresponding invoice&lt;/li&gt;
&lt;li&gt;Status updated: "Paid"&lt;/li&gt;
&lt;li&gt;Cash flow dashboard updated in real time&lt;/li&gt;
&lt;li&gt;Revenue categorized by service/product type&lt;/li&gt;
&lt;li&gt;Client payment history updated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; nothing. Literally. If the amount matches an outstanding invoice, the system closes the loop on its own. If there is a discrepancy (partial payment, different amount), it alerts you to decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Expense → Auto-categorization
&lt;/h3&gt;

&lt;p&gt;You receive a vendor invoice. Snap a photo with your phone. Frihet's AI (OCR + classification) extracts the data: vendor, amount, date, tax, description.&lt;/p&gt;

&lt;p&gt;Then it categorizes: supplies, rent, materials, professional services? And flags whether it is tax-deductible under your filing status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens without your intervention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OCR extracts document data (vendor, tax ID, subtotal, tax, total)&lt;/li&gt;
&lt;li&gt;AI categorizes the expense (8 standard categories)&lt;/li&gt;
&lt;li&gt;Tax deductibility evaluated automatically&lt;/li&gt;
&lt;li&gt;Expense matched to bank transaction if one already exists&lt;/li&gt;
&lt;li&gt;Document stored as supporting evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; verify the AI got it right (most of the time, it does) and approve. Two taps on your phone. If the AI misclassifies, you correct it and the system learns for next time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Bank → Reconciliation
&lt;/h3&gt;

&lt;p&gt;Every day, Frihet syncs your bank transactions. Each incoming transaction is matched against your outstanding invoices. Each outgoing transaction is matched against your recorded expenses.&lt;/p&gt;

&lt;p&gt;The ones that match, reconcile themselves. The ones that do not match get grouped as "pending review" — not as errors, but as questions that need your answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens without your intervention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transactions imported (OFX/CSV or banking API)&lt;/li&gt;
&lt;li&gt;Automatic matching by amount + date + description&lt;/li&gt;
&lt;li&gt;Fuzzy matching for variations (rounding, bank fees)&lt;/li&gt;
&lt;li&gt;Anomalies flagged: unexpected charges, duplicates, discrepancies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; review the anomalies. If your flow is clean, there are few. A reconciliation that used to take 2 hours per month now takes 15 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Taxes → Auto-preparation
&lt;/h3&gt;

&lt;p&gt;End of quarter arrives. Instead of opening 4 spreadsheets and reviewing 90 days of invoices, Frihet already has the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sales tax collected&lt;/strong&gt; (from your issued invoices)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input tax paid&lt;/strong&gt; (from your recorded expenses)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Net amount due or refundable&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax forms pre-filled&lt;/strong&gt; with the quarter's data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimated income tax&lt;/strong&gt; liability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What happens without your intervention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Continuous calculation of quarterly tax obligations&lt;/li&gt;
&lt;li&gt;Alerts before filing deadlines&lt;/li&gt;
&lt;li&gt;Tax forms pre-populated with the quarter's figures&lt;/li&gt;
&lt;li&gt;Invoice ledger (issued and received) kept up to date&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; review the numbers, confirm everything checks out, and file (or hand the data to your accountant). A weekend's worth of work compressed into 30 minutes of review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Reporting → Decisions
&lt;/h3&gt;

&lt;p&gt;Everything above generates data. Data that, without additional effort, Frihet converts into visibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time P&amp;amp;L:&lt;/strong&gt; revenue vs expenses, net margin, monthly trend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cash flow forecast:&lt;/strong&gt; how much money is coming in and going out over the next 30/60/90 days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client profitability:&lt;/strong&gt; who generates the most value vs who consumes the most resources&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expenses by category:&lt;/strong&gt; where your money goes, visualized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business KPIs:&lt;/strong&gt; average ticket, collection rate, DSO (days sales outstanding)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you do:&lt;/strong&gt; open the dashboard. Everything is there. No CSV exports, no report requests, no waiting for month-end close. You make decisions with today's data, not data from 45 days ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integration layer: what connects to what
&lt;/h2&gt;

&lt;p&gt;The flow above works because the pieces communicate. These are the key connections:&lt;/p&gt;

&lt;h3&gt;
  
  
  Frihet ↔ Stripe
&lt;/h3&gt;

&lt;p&gt;Bidirectional connection. Stripe Connect lets you collect payments directly from Frihet invoices (client pays, Frihet knows instantly). Stripe Revenue Sync imports Stripe charges as invoices automatically — ideal if you already collect with Stripe and want your books in order without duplicating work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frihet ↔ Bank
&lt;/h3&gt;

&lt;p&gt;Transaction import via OFX, CSV, or direct banking API. Transactions sync and the reconciliation engine matches each one against recorded invoices and expenses. No manual intervention for clean matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frihet ↔ n8n / Zapier / Make
&lt;/h3&gt;

&lt;p&gt;For custom flows: automatic payment reminders, weekly summaries via Slack or Telegram, unusual expense alerts, invoice creation when a deal closes in your CRM. 14 webhooks + open REST API. No call limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frihet ↔ AI agents via MCP
&lt;/h3&gt;

&lt;p&gt;Frihet's MCP server lets Claude, GPT, and other models access your account programmatically. An agent can: query outstanding invoices, create reminders, generate reports, and answer questions about your business using real data. Not hallucinated data — yours.&lt;/p&gt;

&lt;p&gt;:::callout&lt;br&gt;
All integrations are included in every plan. No premium modules, no add-ons, no surprises.&lt;br&gt;
:::&lt;/p&gt;

&lt;h2&gt;
  
  
  What you still do manually (and why that is a good thing)
&lt;/h2&gt;

&lt;p&gt;Zero Admin does not eliminate your involvement. It eliminates the involvement that adds no value. This is what stays yours:&lt;/p&gt;

&lt;p&gt;:::checklist&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Approve unusual expenses.&lt;/strong&gt; A new charge the AI does not recognize needs your confirmation. As it should.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review anomalies.&lt;/strong&gt; A partial payment, a duplicate charge, a transaction with no matching invoice. Frihet surfaces them. You decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make business decisions.&lt;/strong&gt; Raise prices? Drop a client? Invest in equipment? The dashboard gives you data. The decision is yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Talk to your clients.&lt;/strong&gt; The human relationship does not get automated. Nor should it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review quarterly numbers.&lt;/strong&gt; Preparation is automatic. The sign-off is yours. There should always be a human who says "this is correct."
:::&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a limitation. It is intentional design. Systems that make every decision for you are dangerous. Systems that handle the mechanical decisions and leave you the important ones — those actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before vs After: the real comparison
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The traditional workflow
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Weekly time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Excel / Google Sheets&lt;/td&gt;
&lt;td&gt;Invoice tracking, payment follow-up&lt;/td&gt;
&lt;td&gt;2h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banking app&lt;/td&gt;
&lt;td&gt;Review transactions, download statements&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoicing software&lt;/td&gt;
&lt;td&gt;Create invoices, send PDFs&lt;/td&gt;
&lt;td&gt;1.5h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Payment reminders, follow-up threads&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expense folder&lt;/td&gt;
&lt;td&gt;Organize receipts, classify&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax spreadsheet&lt;/td&gt;
&lt;td&gt;Calculate sales tax, prep quarterly filing&lt;/td&gt;
&lt;td&gt;1.5h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your accountant&lt;/td&gt;
&lt;td&gt;Calls, send documents, resolve questions&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRM / notes&lt;/td&gt;
&lt;td&gt;Update client status&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~10h/week&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;15+ tools. 10+ hours per week. Fragmented data. Transcription errors. And at the end of every quarter, a weekend lost reconciling numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Zero Admin stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Weekly time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frihet&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Invoicing, expenses, clients, reporting, taxes&lt;/td&gt;
&lt;td&gt;1h (review)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Payments, payment gateway&lt;/td&gt;
&lt;td&gt;0 (automatic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transaction sync&lt;/td&gt;
&lt;td&gt;15min (review anomalies)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;n8n&lt;/strong&gt; (optional)&lt;/td&gt;
&lt;td&gt;Reminders, alerts, custom flows&lt;/td&gt;
&lt;td&gt;0 (automatic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.5h/week&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;4 tools. 1.5 hours per week. A single source of truth for all your financial data. And at the end of the quarter, 30 minutes of review.&lt;/p&gt;

&lt;p&gt;:::quote&lt;br&gt;
The difference is not technological. It is structural. One connected system vs 15 disconnected tools. Data that flows vs data you copy by hand.&lt;br&gt;
:::&lt;/p&gt;

&lt;h2&gt;
  
  
  How to set it up in under 1 hour
&lt;/h2&gt;

&lt;p&gt;You do not need a weekend. You need one focused hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minute 0-15: Your Frihet account
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create your account at &lt;a href="https://app.frihet.io" rel="noopener noreferrer"&gt;app.frihet.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Complete the welcome wizard: tax details, logo, invoice series&lt;/li&gt;
&lt;li&gt;Import your clients (CSV or manual — if you have a handful, manual is faster)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Minute 15-30: Connect Stripe
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to Settings → Integrations → Stripe&lt;/li&gt;
&lt;li&gt;Connect your Stripe account (OAuth, one click)&lt;/li&gt;
&lt;li&gt;Enable Revenue Sync if you already collect with Stripe (it will import historical invoices)&lt;/li&gt;
&lt;li&gt;Enable Stripe Connect if you want to collect payments directly from Frihet invoices&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Minute 30-45: Connect your bank
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Import your first bank statement (OFX or CSV)&lt;/li&gt;
&lt;li&gt;Review the automatic reconciliation suggestions&lt;/li&gt;
&lt;li&gt;Confirm or correct the first matches — the system learns from your corrections&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Minute 45-60: Your first flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a quote for a real client&lt;/li&gt;
&lt;li&gt;Convert it to an invoice&lt;/li&gt;
&lt;li&gt;Log an expense with your phone camera&lt;/li&gt;
&lt;li&gt;Open the dashboard — you already have visibility&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From here, everything flows. Every new invoice, every new expense, every new payment feeds the system. No duplicate data entry, no copying between tabs, no "I will deal with it on Sunday."&lt;/p&gt;

&lt;h2&gt;
  
  
  The best admin is the admin you never do
&lt;/h2&gt;

&lt;p&gt;Administrative work is not your job. It is the infrastructure that supports your job. And like all good infrastructure, it should be invisible.&lt;/p&gt;

&lt;p&gt;Zero Admin is not a magic promise. It is an architecture. A system where each piece knows what to do with the information it receives, passes it to the next piece without asking permission, and only alerts you when it needs a decision that only you can make.&lt;/p&gt;

&lt;p&gt;The time you recover is not abstract. It is real hours you can spend on what generates value: closing projects, serving clients, building product, or simply not working on a Sunday.&lt;/p&gt;

&lt;p&gt;Setup takes one hour. The return starts in week one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 5 of "Building an AI-Native ERP". Available in &lt;a href="https://www.frihet.io/en/blog/zero-admin-stack-business-autopilot" rel="noopener noreferrer"&gt;17 languages&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.frihet.io" rel="noopener noreferrer"&gt;Frihet&lt;/a&gt;&lt;/strong&gt; — Free, AI-native ERP. &lt;a href="https://app.frihet.io" rel="noopener noreferrer"&gt;Try it&lt;/a&gt; · &lt;a href="https://docs.frihet.io" rel="noopener noreferrer"&gt;Docs&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@frihet/mcp-server" rel="noopener noreferrer"&gt;MCP Server&lt;/a&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>productivity</category>
      <category>saas</category>
    </item>
    <item>
      <title>The ERP Is Dead: Why Your Business Needs an AI Operating System</title>
      <dc:creator>berthelius</dc:creator>
      <pubDate>Sat, 04 Apr 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/frihet/the-erp-is-dead-why-your-business-needs-an-ai-operating-system-25gc</link>
      <guid>https://dev.to/frihet/the-erp-is-dead-why-your-business-needs-an-ai-operating-system-25gc</guid>
      <description>&lt;p&gt;You open your ERP. Navigate three menus. Fill out a 14-field form. Hit save. Repeat. This is not management. This is work about work.&lt;/p&gt;

&lt;p&gt;And yet, for two decades, this has been the standard. Software that records what you already know, organizes what you already did, and shows you reports about what already happened. Millions of professionals open their ERP every day not because it gives them clarity, but because they have no alternative.&lt;/p&gt;

&lt;p&gt;That era is over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ERPs got right (and why it is no longer enough)
&lt;/h2&gt;

&lt;p&gt;Credit where it is due: ERPs were revolutionary. Before SAP, Sage, or even QuickBooks, business management lived in filing cabinets, spreadsheets, and the accountant's memory. ERPs centralized data, standardized processes, and created a single source of truth for a company's finances.&lt;/p&gt;

&lt;p&gt;That leap was massive. From the folder called &lt;code&gt;INVOICES_FINAL_FINAL_v3&lt;/code&gt; to a system with automatic numbering and auditable records. From the expense notebook to a real-time balance sheet. From chaos to order.&lt;/p&gt;

&lt;p&gt;But order is not intelligence. And that is where ERPs froze.&lt;/p&gt;

&lt;p&gt;What was revolutionary in 2005 is baseline infrastructure in 2026. Centralizing data is not a competitive advantage -- it is the minimum. And most ERPs, including ones marketed as "modern" or "cloud-native," still operate with the same logic: you enter data, the software stores it, you query it.&lt;/p&gt;

&lt;p&gt;They are databases with nice forms. Spreadsheets with better UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three problems no traditional ERP can solve
&lt;/h2&gt;

&lt;p&gt;The limitation of legacy ERPs is not a missing feature list. It is an architecture problem. There are three structural flaws that updates cannot fix:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. They are reactive
&lt;/h3&gt;

&lt;p&gt;A traditional ERP does nothing until you do something. It does not alert you that a client is 45 days past due until you pull the aging report. It does not catch that you are applying the wrong sales tax rate until your accountant flags it at quarter-end. It does not suggest categorizing a purchase as a deductible capital expense because it does not know what a capital expense is.&lt;/p&gt;

&lt;p&gt;The software waits. You work. If you make a mistake, you find out late.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. They are silos
&lt;/h3&gt;

&lt;p&gt;Your ERP does not talk to your bank. It does not talk to Stripe. It does not talk to your e-commerce platform. It does not talk to your accountant's system. And when it "talks," it does so through brittle integrations that require manual configuration, CSV exports, and hand-matched reconciliations.&lt;/p&gt;

&lt;p&gt;The result is that your business data lives fragmented across 8 different tools. Your ERP has the invoices, the bank has the transactions, Stripe has the charges, the spreadsheet has the forecasts. And you are the human connector keeping everything in sync.&lt;/p&gt;

&lt;p&gt;That synchronization work eats 5 to 15 hours a week. It is not management. It is digital plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. They are one-size-fits-all
&lt;/h3&gt;

&lt;p&gt;A freelance designer in Brooklyn and a restaurant chain in Dallas use the same software with the same forms, the same menus, and the same reports. Customization, when it exists, means configuring which fields to show or hide.&lt;/p&gt;

&lt;p&gt;But real intelligence is not configuration. It is context. A good system should know that your business invoices 80% of clients in the EU and pre-apply reverse charge. It should know that you always invoice in multiples of 500 and flag when an amount deviates. It should know that every April you need your Q1 tax estimate and have it ready before you go looking for it.&lt;/p&gt;

&lt;p&gt;Traditional ERPs do not learn. They are identical the day you install them and three years later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AI operating system for business actually looks like
&lt;/h2&gt;

&lt;p&gt;The alternative is not an ERP with a chatbot glued on. It is a new category: software where artificial intelligence is the architectural foundation. Not a feature. The foundation.&lt;/p&gt;

&lt;p&gt;Here is what changes:&lt;/p&gt;

&lt;h3&gt;
  
  
  From reactive to proactive
&lt;/h3&gt;

&lt;p&gt;The software analyzes payment patterns and alerts you before a client falls behind. It detects tax anomalies when you create the invoice, not when the auditor calls. It calculates your estimated quarterly taxes in real time, not when your accountant asks for the data.&lt;/p&gt;

&lt;p&gt;It does not wait for you to ask. It anticipates.&lt;/p&gt;

&lt;h3&gt;
  
  
  From silo to connected ecosystem
&lt;/h3&gt;

&lt;p&gt;Integrations are not fragile bridges between islands. They are native connections. Stripe charges become invoices automatically. Bank transactions reconcile without intervention. Data flows between tools because the system was designed for it, not patched to simulate it.&lt;/p&gt;

&lt;p&gt;And with protocols like MCP (Model Context Protocol), connectivity goes beyond traditional integrations. An AI agent can query your revenue, generate quotes, or analyze your cash flow without you opening any application. Your business software becomes a tool that other systems can use autonomously.&lt;/p&gt;

&lt;h3&gt;
  
  
  From generic to personalized
&lt;/h3&gt;

&lt;p&gt;The system learns how you work. Which expense categories you use most. Which clients pay late. What type of invoices you issue most frequently. And it uses that context to accelerate every interaction.&lt;/p&gt;

&lt;p&gt;This is not personalization through configuration. It is personalization through observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real examples: this works today
&lt;/h2&gt;

&lt;p&gt;This is not theoretical. These capabilities exist now in AI-native software:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic tax intelligence.&lt;/strong&gt; You create an invoice for a client in a different tax jurisdiction. The system detects the zone, applies the correct tax regime, adjusts withholding based on your filing status, and calculates the taxable amount. It does not ask. It does it. And if something does not add up, it flags it before you hit send.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expense categorization via OCR.&lt;/strong&gt; You photograph a restaurant receipt. The AI extracts the amount, date, vendor, and tax ID. It categorizes the expense as "client entertainment" based on your history. It links it to the correct project. Time spent: 3 seconds. Time saved versus doing it manually: 4 minutes. Multiply that by 200 expenses a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversational copilot with real context.&lt;/strong&gt; You ask your AI assistant: "How much do clients owe me this month?" No report to open, no date filter to set, no manual addition required. It responds with real-time data, broken down by client, with aging days for each invoice. And if you ask it to send a payment reminder to the most overdue account, it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent interoperability.&lt;/strong&gt; Your accountant uses Claude with your ERP's MCP server. Without opening your application, they query your quarterly invoices, verify tax rates, and download the invoice ledger. Your software works for you even when you are not using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "adding AI" to legacy software does not work
&lt;/h2&gt;

&lt;p&gt;Here is the trap most vendors fall into. They take software designed 10 or 15 years ago, attach a chatbot, call it "AI-powered," and raise the price.&lt;/p&gt;

&lt;p&gt;It is like strapping a GPS to a horse carriage and calling it a self-driving vehicle.&lt;/p&gt;

&lt;p&gt;The problem is architectural. A legacy ERP has data in rigid tables, linear workflows, and an interface designed for the human to do all the work. Bolting AI on top of that structure limits it to what the structure allows: answering questions about data that already exists, in formats the system already knows.&lt;/p&gt;

&lt;p&gt;:::quote&lt;br&gt;
Bolt-on AI answers questions. Born-in AI makes decisions.&lt;br&gt;
:::&lt;/p&gt;

&lt;p&gt;In an AI-native system, artificial intelligence has access to the entire value chain. It is not confined to a chatbot in a corner. It can intercept an invoice before it ships to correct a tax error. It can reclassify an expense retroactively when it learns something new about your business. It can generate a cash flow forecast that combines bank data, outstanding invoices, and seasonal patterns.&lt;/p&gt;

&lt;p&gt;None of that is possible when AI is a superficial layer on top of a passive database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for in AI-native business software
&lt;/h2&gt;

&lt;p&gt;If you are evaluating tools, these are the signals that separate the real from the marketing:&lt;/p&gt;

&lt;p&gt;:::checklist&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI from day one, not as an update.&lt;/strong&gt; If the vendor shipped AI as a feature in a recent changelog, it is bolt-on. If AI is part of how the product works since its inception, it is born-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation without configuration.&lt;/strong&gt; Repetitive tasks should automate without you building rules, flows, or "recipes." If you need a flowchart to automate something basic, it is not AI-native.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectivity as a principle.&lt;/strong&gt; Documented API, webhooks, MCP server, native integrations with the tools you already use. If exporting data requires a CSV or an email to support, run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context that improves with use.&lt;/strong&gt; The software should be faster and more useful after 6 months than on day one. If the experience is identical at the start and a year in, it is not learning anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparency about what the AI does.&lt;/strong&gt; Every automated decision should be visible, explainable, and reversible. If the AI operates as a black box, do not trust it with your finances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your data, always.&lt;/strong&gt; Full export, open format, no exit fees. If the vendor charges you to extract your own data, their business model depends on you not being able to leave.
:::&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shift is happening now
&lt;/h2&gt;

&lt;p&gt;You do not need to wait until 2030. The convergence of three forces is accelerating this transition today:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regulation.&lt;/strong&gt; Across the globe, governments are tightening requirements on invoicing software. In the EU, e-invoicing mandates are expanding. In the US, IRS reporting rules keep growing in complexity. Many businesses will have to change software anyway. That is the perfect moment to leap to a superior category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technology.&lt;/strong&gt; Language models, computer vision, and interoperability protocols (MCP, OpenAPI) have reached a maturity level that makes integrating real AI into business software viable without compromising reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expectations.&lt;/strong&gt; If your personal AI assistant can book a flight, manage your calendar, and summarize a 50-page document, why is your invoicing software still asking you to fill out forms by hand? Tolerance for passive interfaces is collapsing.&lt;/p&gt;

&lt;p&gt;Traditional ERPs will not vanish overnight. SAP will keep selling enterprise licenses. QuickBooks will still have customers. But the "ERP" category as we know it -- reactive, generic, siloed software -- is entering its terminal phase.&lt;/p&gt;

&lt;p&gt;What replaces it is not another ERP with more features. It is a different way of thinking about business software: a system that works with you, not one you work inside of.&lt;/p&gt;

&lt;p&gt;:::quote&lt;br&gt;
Management should not eat your week. It should disappear.&lt;br&gt;
:::&lt;/p&gt;

&lt;p&gt;That is what we are building at Frihet. Not an ERP with AI. A business operating system where artificial intelligence is not a feature -- it is the reason the software exists.&lt;/p&gt;

&lt;p&gt;Less management. More freedom.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 4 of "Building an AI-Native ERP". Available in &lt;a href="https://www.frihet.io/en/blog/erp-is-dead-business-needs-ai-operating-system" rel="noopener noreferrer"&gt;17 languages&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.frihet.io" rel="noopener noreferrer"&gt;Frihet&lt;/a&gt;&lt;/strong&gt; — Free, AI-native ERP. &lt;a href="https://app.frihet.io" rel="noopener noreferrer"&gt;Try it&lt;/a&gt; · &lt;a href="https://docs.frihet.io" rel="noopener noreferrer"&gt;Docs&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@frihet/mcp-server" rel="noopener noreferrer"&gt;MCP Server&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>startup</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Automate Your Invoicing with Claude Code + n8n + a REST API</title>
      <dc:creator>berthelius</dc:creator>
      <pubDate>Tue, 10 Mar 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/frihet/automate-your-invoicing-with-claude-code-n8n-a-rest-api-27b0</link>
      <guid>https://dev.to/frihet/automate-your-invoicing-with-claude-code-n8n-a-rest-api-27b0</guid>
      <description>&lt;p&gt;The average freelancer spends 5-8 hours per month on admin tasks that a computer could handle in seconds: sending payment reminders, classifying expenses, generating reports. These tasks aren't difficult. They just shouldn't require your attention.&lt;/p&gt;

&lt;p&gt;Here's how I automated 80% of my business admin with three tools: an ERP with a REST API (&lt;a href="https://frihet.io" rel="noopener noreferrer"&gt;Frihet&lt;/a&gt;), an AI assistant (Claude Code), and a workflow automator (n8n). No coding required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Pieces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your ERP&lt;/strong&gt; — Where invoices, clients, expenses live. The critical requirement: it must have a &lt;strong&gt;REST API&lt;/strong&gt; and &lt;strong&gt;webhooks&lt;/strong&gt;. Without these, automation is impossible. Most ERPs don't offer both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;n8n&lt;/strong&gt; — Visual workflow automator. If X happens, then do Y. Open source, self-hostable, drag-and-drop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code + MCP&lt;/strong&gt; — AI assistant that can interact with your ERP directly through the MCP protocol. You talk, it executes.&lt;/p&gt;

&lt;p&gt;All three have free tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automation 1: Payment Reminders (ROI: Immediate)
&lt;/h2&gt;

&lt;p&gt;The most profitable automation. Unpaid invoices without reminders = money that arrives late or never.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invoice becomes overdue (webhook)
    → Wait 3 days (grace period)
    → Check: still unpaid? (API call)
    → Yes → Email reminder to client
    → Wait 7 more days
    → Still unpaid? → Second reminder (firmer tone)
    → Wait 8 more days
    → Still unpaid? → Notify YOU via Telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  n8n Setup (5 nodes)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhook Trigger&lt;/strong&gt; — Receives &lt;code&gt;invoice.overdue&lt;/code&gt; event with invoice ID, client, amount, due date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait (3 days)&lt;/strong&gt; — Grace period&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Request&lt;/strong&gt; — &lt;code&gt;GET /v1/invoices/{id}&lt;/code&gt; to check if still unpaid&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send Email&lt;/strong&gt; — "Reminder: Invoice [number] pending payment" with payment link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If/Else&lt;/strong&gt; — Second reminder at day 10, personal notification at day 15&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tone progression matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 3: Informational ("We'd like to remind you...")&lt;/li&gt;
&lt;li&gt;Day 10: Direct ("This invoice has been overdue for 10 days")&lt;/li&gt;
&lt;li&gt;Day 15: For you ("Take action on this one")&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Automation 2: Weekly Financial Summary
&lt;/h2&gt;

&lt;p&gt;Every Monday at 9:00 you receive your business metrics. No dashboards, no spreadsheets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cron: Monday 9:00
    → GET /v1/invoices?date_from=last_monday
    → GET /v1/expenses?date_from=last_monday
    → Calculate: issued, collected, pending, expenses, net
    → Format message
    → Send via Telegram/Email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Data Included
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Invoices issued this week (count + total)&lt;/li&gt;
&lt;li&gt;Payments received&lt;/li&gt;
&lt;li&gt;Total pending collection&lt;/li&gt;
&lt;li&gt;Expenses recorded&lt;/li&gt;
&lt;li&gt;Net balance (collected - expenses)&lt;/li&gt;
&lt;li&gt;Urgent: invoices due this week&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This flow only uses GET endpoints — reads data, presents it usefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automation 3: Stripe → Invoice (Auto)
&lt;/h2&gt;

&lt;p&gt;If you collect via Stripe, invoices should create themselves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe payment_intent.succeeded (webhook to n8n)
    → Extract: client email, amount, description
    → Lookup client in ERP by email
    → Client not found? → Create client
    → Create invoice with payment data
    → ERP sends invoice to client automatically
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Client pays on your website → receives invoice in email in under a minute → invoice recorded in ERP with tax compliance. Zero human intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code + MCP: The AI Layer
&lt;/h2&gt;

&lt;p&gt;With an MCP server, Claude Code interacts directly with your ERP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You: "Create an invoice for Company ABC for 3 hours
      of consulting at 80 EUR/hour, with 21% VAT"

Claude: [calls create_invoice via MCP]
→ Looks up "Company ABC" in your database
→ Creates invoice: 3 × 80 EUR + 21% VAT = 290.40 EUR
→ Confirms details
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No copy-pasting. No opening the ERP. No calculating VAT.&lt;/p&gt;

&lt;p&gt;Other things Claude can do via MCP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query current month's invoicing&lt;/li&gt;
&lt;li&gt;List invoices pending payment&lt;/li&gt;
&lt;li&gt;Create quotes for clients&lt;/li&gt;
&lt;li&gt;Search clients by name or tax ID&lt;/li&gt;
&lt;li&gt;Generate expense reports&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Most ERPs Can't Do This
&lt;/h2&gt;

&lt;p&gt;Automation requires three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open API&lt;/strong&gt; — Many ERPs charge extra for API access or don't offer one at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real webhooks&lt;/strong&gt; — Events that fire in real-time (not polling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP server&lt;/strong&gt; — For native AI assistant integration&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without all three, you're stuck with manual work or fragile workarounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With One
&lt;/h2&gt;

&lt;p&gt;Don't set up everything at once. Pick the automation that saves you the most time — for most freelancers it's payment reminders — and configure it this week.&lt;/p&gt;

&lt;p&gt;Once it works, add the next. Within a month, 80% of your admin runs on its own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://frihet.io" rel="noopener noreferrer"&gt;Frihet&lt;/a&gt; — an ERP with a REST API, 14 webhook events, and an MCP server. If you want to try these automations: &lt;a href="https://app.frihet.io" rel="noopener noreferrer"&gt;app.frihet.io&lt;/a&gt; (free plan). The MCP server is open source: &lt;a href="https://github.com/berthelius/frihet-mcp" rel="noopener noreferrer"&gt;github.com/berthelius/frihet-mcp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
