<?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: Darius Ceponas</title>
    <description>The latest articles on DEV Community by Darius Ceponas (@darius_ceponas_2b7889e363).</description>
    <link>https://dev.to/darius_ceponas_2b7889e363</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%2F4112419%2F0d0cc352-cc53-4a3b-9d58-0f98ea901605.PNG</url>
      <title>DEV Community: Darius Ceponas</title>
      <link>https://dev.to/darius_ceponas_2b7889e363</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darius_ceponas_2b7889e363"/>
    <language>en</language>
    <item>
      <title>Human approval before an AI agent pays: a pattern that survives an audit</title>
      <dc:creator>Darius Ceponas</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:46:29 +0000</pubDate>
      <link>https://dev.to/darius_ceponas_2b7889e363/human-approval-before-an-ai-agent-pays-a-pattern-that-survives-an-audit-13hp</link>
      <guid>https://dev.to/darius_ceponas_2b7889e363/human-approval-before-an-ai-agent-pays-a-pattern-that-survives-an-audit-13hp</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://raposa.group/blog/human-approval-before-agent-payment/" rel="noopener noreferrer"&gt;raposa.group&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure we are designing against
&lt;/h2&gt;

&lt;p&gt;An agent that can call a refund or payout API will, sooner or later, call it with the wrong number. The causes are boring and well documented: a hallucinated amount, a tool called twice after a retry, an instruction smuggled in through a ticket body or a web page. None of them look like an attack from inside the agent loop — the model is confident every time.&lt;/p&gt;

&lt;p&gt;The usual first fix is to ask the model to double-check itself ("are you sure this refund is correct?"). That is not a control. The same model that produced the number is judging it, and a prompt injection reaches both steps. The second fix — a hard-coded threshold that blocks anything above €100 — is a control, but a blunt one: it stops the agent from being useful exactly on the cases where it would save the most time.&lt;/p&gt;

&lt;p&gt;What finance teams actually ask for is simpler to state: &lt;strong&gt;a named person presses Approve before money moves, and afterwards we can prove who it was.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Put a gate between "the agent decided to pay" and "the payment API was called". The gate has exactly three outcomes, and your code has to handle all three explicitly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;What the agent does&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A human approved&lt;/td&gt;
&lt;td&gt;Proceed. Store the approval id next to your own transaction record.&lt;/td&gt;
&lt;td&gt;The id is the link between your ledger and the audit trail.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected, or the request expired&lt;/td&gt;
&lt;td&gt;Do not proceed. Tell the user why.&lt;/td&gt;
&lt;td&gt;A rejection is information for the customer conversation, not a silent failure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nobody answered before your timeout&lt;/td&gt;
&lt;td&gt;Do not proceed.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Silence is not consent.&lt;/strong&gt; Most home-grown gates get this wrong and fall through to "approved".&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three more rules that separate a gate from a prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The approver is outside the model.&lt;/strong&gt; The approve button lives in a signed email link, a Telegram card or a Slack message. Opening the message decides nothing; only the button does. The approver never needs access to the agent runtime, and the agent never sees the approver's credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent's key cannot approve.&lt;/strong&gt; If the same credential can both request and grant, an injected instruction can do both. Requesting and deciding must be two different principals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every decision is sealed.&lt;/strong&gt; Who, when, with which comment — appended to a hash chain where each entry hashes the previous one. Editing or deleting a past decision breaks verification from that point on, so tampering is detectable rather than merely discouraged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation, three ways
&lt;/h2&gt;

&lt;p&gt;The examples use &lt;a href="https://raposa.group/" rel="noopener noreferrer"&gt;Raposa Aval&lt;/a&gt;, the hosted version of this pattern (EU-hosted, GDPR, free sandbox of 100 approvals a month). The shape is the same if you build the gate yourself — the point of the article is the shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Python, inside your own agent code
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pip install raposa&lt;/code&gt; — a zero-dependency client. &lt;code&gt;guard()&lt;/code&gt; returns only when a named person approved; rejection or expiry raises &lt;code&gt;Denied&lt;/code&gt;, silence raises &lt;code&gt;TimedOut&lt;/code&gt;. The refund call sits &lt;em&gt;after&lt;/em&gt; the guard and nowhere else.&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;raposa&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Raposa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimedOut&lt;/span&gt;

&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Raposa&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# reads RAPOSA_API_KEY from the environment
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;issue_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_eur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&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="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&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;Order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, EUR &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount_eur&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Reason: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reason&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="n"&gt;risk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;amount_eur&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;requested_by&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-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;wait_sec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;Denied&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;Refund not issued — a human declined: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TimedOut&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;Refund not issued — nobody answered in time: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_eur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# the real call, only here
&lt;/span&gt;    &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approval_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;approved_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decided_by&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decided_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refund issued. Approved by &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;decided_by&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth copying even if you never use the library. First, the &lt;code&gt;risk&lt;/code&gt; field is set by your code from the amount, not by the model — the model does not get to grade its own request. Second, the approval id goes into your ledger; that is what turns "we have an audit log" into "here is the entry for this transaction".&lt;/p&gt;

&lt;p&gt;Without the SDK it is one POST and a poll. Create: &lt;code&gt;POST /api/v1/approvals&lt;/code&gt; with &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;context&lt;/code&gt;, &lt;code&gt;risk&lt;/code&gt; and &lt;code&gt;requested_by&lt;/code&gt; (the schema is strict — unknown fields are rejected, and creation is limited to 60 requests a minute per key). Read: &lt;code&gt;GET /api/v1/approvals/{id}&lt;/code&gt; until &lt;code&gt;status&lt;/code&gt; is no longer &lt;code&gt;pending&lt;/code&gt;. A pending approval past &lt;code&gt;expires_at&lt;/code&gt; flips to &lt;code&gt;expired&lt;/code&gt; on read, and that transition is audited like a decision. If you would rather not poll, pass a &lt;code&gt;webhook_url&lt;/code&gt;: the decision is POSTed with an HMAC-SHA256 signature in &lt;code&gt;X-Raposa-Signature&lt;/code&gt;, retried four times, and every attempt is logged.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. MCP, for Claude Code, Cursor or any tool-calling agent
&lt;/h3&gt;

&lt;p&gt;If the agent is an LLM with tools rather than your own code, give it the gate as a tool. The &lt;code&gt;raposa-mcp&lt;/code&gt; server exposes &lt;code&gt;request_human_approval(action, context, risk)&lt;/code&gt;, which returns &lt;code&gt;approved: true&lt;/code&gt; only when a person pressed Approve. Timeout, expiry and rejection all come back as &lt;code&gt;approved: false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add raposa &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;RAPOSA_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your key&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; uvx raposa-mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or over HTTP, with nothing to install — the same three tools are served at &lt;code&gt;https://dcescrypt.com/api/mcp&lt;/code&gt; (Streamable HTTP, stateless):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&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="nl"&gt;"raposa"&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="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dcescrypt.com/api/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                          &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &amp;lt;your key&amp;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 system prompt does the rest: "Before any payment, refund or transfer, call &lt;code&gt;request_human_approval&lt;/code&gt; and proceed only if it returns &lt;code&gt;approved: true&lt;/code&gt;." The key is read from the environment and never appears in tool inputs or outputs, so the model cannot leak it and an injected page cannot use it. Over HTTP each call waits up to 50 seconds; if nobody has decided by then the tool returns &lt;code&gt;status: "timeout", approved: false&lt;/code&gt; and the id, and the request stays open for &lt;code&gt;get_approval&lt;/code&gt; later.&lt;/p&gt;

&lt;p&gt;This is also the honest answer to "can't the agent just be told to ask?" — it can, and it will comply most of the time. The tool makes the ask real: the agent cannot fabricate an approval, because approval is a state on a server it does not control.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. n8n, with no code
&lt;/h3&gt;

&lt;p&gt;In n8n the gate is a node. Install &lt;code&gt;n8n-nodes-raposa&lt;/code&gt; (Settings → Community Nodes), drop a &lt;strong&gt;Raposa Approval&lt;/strong&gt; node in front of the Stripe, bank or ERP step, set the operation to &lt;em&gt;Ask and Wait&lt;/em&gt;. The workflow pauses until a human decides. A timeout is an error, never an approval; a rejection stops the workflow unless you turn &lt;em&gt;Fail on Reject&lt;/em&gt; off and branch on &lt;code&gt;status&lt;/code&gt; yourself. The same node works as a tool for n8n's AI Agent, so a chat-driven agent gets the same gate as a scheduled workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who approves, and from where
&lt;/h2&gt;

&lt;p&gt;The approver is a person on &lt;em&gt;your&lt;/em&gt; team. You create approvers on your account page or with &lt;code&gt;POST /v1/approvers&lt;/code&gt;; each gets a console login scoped to your approvals only, optional TOTP, and can bind Telegram or Slack so the approve button arrives where they already are. Their name is recorded in the decision (&lt;code&gt;decided_by: "approver:anna"&lt;/code&gt;), in the webhook and in your audit export. There is no per-seat price — adding an approver never changes the bill — because a gate that makes you ration approvers is a gate people route around.&lt;/p&gt;

&lt;p&gt;For payments specifically, two options matter. &lt;code&gt;approvers: ["finance", "cfo"]&lt;/code&gt; with &lt;code&gt;required: 2&lt;/code&gt; gives you N-of-M sign-off on large amounts. &lt;code&gt;remind_after_sec&lt;/code&gt; with &lt;code&gt;escalate_to&lt;/code&gt; pulls in a second approver when the first one has not answered — so a request does not silently expire because someone is on a plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it afterwards
&lt;/h2&gt;

&lt;p&gt;An auditor's question is never "did you have a process"; it is "show me the entry for this transaction". That is why the approval id goes in your ledger. From there, &lt;code&gt;GET /api/v1/audit/export&lt;/code&gt; returns the entries for your approvals, each with its position in the chain, its &lt;code&gt;prev_hash&lt;/code&gt; and &lt;code&gt;hash&lt;/code&gt;, and a recomputed &lt;code&gt;self_hash_ok&lt;/code&gt;. Each entry is &lt;code&gt;sha256(prev_hash + ts + event_type + approval_id + actor + payload)&lt;/code&gt;. You cannot recompute other customers' entries — isolation forbids it — but the chain as a whole is verified continuously on the operator side, and any break is a page, not a footnote.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep the id. Export the chain. Those two habits are the difference between "we log approvals" and evidence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Failure modes to design for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries.&lt;/strong&gt; A retried tool call must not create a second payment. Create the approval once, keep the id, and make the payment step idempotent on that id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double decisions.&lt;/strong&gt; Two approvers clicking at once must not produce two outcomes. A second decision on the same approval answers &lt;code&gt;409&lt;/code&gt;; treat it as "already decided, re-read the status".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry.&lt;/strong&gt; Pick &lt;code&gt;expires_in_sec&lt;/code&gt; from the business, not from the HTTP timeout: a refund request nobody looked at for a day should expire, and the customer should hear why, rather than being paid on Monday by a queue that woke up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection.&lt;/strong&gt; Assume the agent's context is hostile. The gate is worth having precisely because the approver reads the &lt;code&gt;action&lt;/code&gt; and &lt;code&gt;context&lt;/code&gt; fields — so write them in plain, specific language ("Refund EUR 240 to customer 9182 for order 7731, duplicate charge") rather than pasting the model's reasoning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;The sandbox is free — 100 approvals a month, no card, no expiry — and includes the whole product: API, console, approve from email, Telegram or Slack, approver groups with N-of-M, reminders, escalation, webhooks and the audit export. Team is €149 a month for 2,500 approvals, unlimited approvers. A side-by-side with an SDK approval pause and other vendors is on the &lt;a href="https://raposa.group/compare/" rel="noopener noreferrer"&gt;compare page&lt;/a&gt;, where every claim about us is a named test that ran today.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://raposa.group/docs/" rel="noopener noreferrer"&gt;https://raposa.group/docs/&lt;/a&gt; · MCP server and n8n node are MIT on GitHub (agentlabbusiness). Questions about the polling/timeout design welcome in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>security</category>
      <category>n8n</category>
    </item>
    <item>
      <title>Intesta: an attested fact registry that AI agents query through MCP instead of scraping stale pages</title>
      <dc:creator>Darius Ceponas</dc:creator>
      <pubDate>Sun, 06 Sep 2026 14:38:04 +0000</pubDate>
      <link>https://dev.to/darius_ceponas_2b7889e363/intesta-an-attested-fact-registry-that-ai-agents-query-through-mcp-instead-of-scraping-stale-pages-48d9</link>
      <guid>https://dev.to/darius_ceponas_2b7889e363/intesta-an-attested-fact-registry-that-ai-agents-query-through-mcp-instead-of-scraping-stale-pages-48d9</guid>
      <description>&lt;p&gt;AI agents already answer questions about small businesses. Most of the time they do it from stale pages, and when the page is missing they guess. After watching agents confidently misstate delivery terms for shops near me, I built &lt;strong&gt;Intesta&lt;/strong&gt; — a public registry where a business publishes its own facts once and every agent reads the same attested source.&lt;/p&gt;

&lt;p&gt;Site: &lt;a href="https://intesta.io" rel="noopener noreferrer"&gt;https://intesta.io&lt;/a&gt; · Benchmark vs. a scraping agent: &lt;a href="https://intesta.io/benchmark" rel="noopener noreferrer"&gt;https://intesta.io/benchmark&lt;/a&gt; · Public traffic stats: &lt;a href="https://intesta.io/stats" rel="noopener noreferrer"&gt;https://intesta.io/stats&lt;/a&gt;&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;A business registers an entity and proves domain control (a DNS TXT record or a file under &lt;code&gt;/.well-known/intesta&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It publishes facts: delivery, returns, fees, contacts, opening hours. Each fact is attested and appended to a ledger, so history is visible on the public passport page.&lt;/li&gt;
&lt;li&gt;Everyone reads the same core through two interfaces:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agents:&lt;/strong&gt; an MCP endpoint and a plain HTTP API, plus an A2A agent card at &lt;code&gt;/.well-known/agent.json&lt;/code&gt; and &lt;code&gt;/llms.txt&lt;/code&gt; for discovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People:&lt;/strong&gt; a small "Verified facts" widget dropped on the business site with one script tag.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The rule that matters: refuse instead of guessing
&lt;/h2&gt;

&lt;p&gt;The answer engine only answers from the passport. Retrieval is lexical first (token overlap with a stop-list and a threshold we measured — lowering it produced false answers), then a semantic fallback on embeddings (NVIDIA nemotron, cosine with absolute and relative thresholds). If nothing passes, the agent gets an explicit refusal and the question is logged for the owner as "uncovered".&lt;/p&gt;

&lt;p&gt;For humans there is an optional rephrasing step through a small LLM. Every generated sentence goes through a grounding verifier: at least 75 % of its tokens must come from the facts it cites, and every number, URL and e-mail must exist in those facts. A sentence that fails is dropped and the verbatim facts are shown instead.&lt;/p&gt;

&lt;p&gt;Off-topic questions ("what's the weather") are detected against the entity's vocabulary and answered with a scope message rather than a refusal, so the owner's "uncovered questions" list stays useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;FastAPI + Postgres, nginx in front, systemd timers for backups, retention, traffic aggregation and IndexNow pings. Traffic stats are computed from the access log as counters only — no IPs or user agents are stored. Tests: 190 pytest cases, including an adversarial set of questions designed to make the engine invent things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like feedback on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is "refuse when the fact is missing" the right default for agents, or should there be a "best effort, flagged" mode?&lt;/li&gt;
&lt;li&gt;What would a small business actually want to put in its passport beyond delivery/returns/contacts?&lt;/li&gt;
&lt;li&gt;If you build sites for clients: would an "agent front" per site be a line in your price list?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Registration and single queries are free. The MCP endpoint is listed on Glama and Smithery; try it with any MCP client.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>showdev</category>
      <category>python</category>
    </item>
  </channel>
</rss>
