<?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: Shakir Riyaz</title>
    <description>The latest articles on DEV Community by Shakir Riyaz (@shakirriyaz).</description>
    <link>https://dev.to/shakirriyaz</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%2F4079585%2Feae4a186-1d73-4eda-aec4-8900c5386dbc.png</url>
      <title>DEV Community: Shakir Riyaz</title>
      <link>https://dev.to/shakirriyaz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shakirriyaz"/>
    <language>en</language>
    <item>
      <title>Add human approval to a LangChain agent in 5 minutes</title>
      <dc:creator>Shakir Riyaz</dc:creator>
      <pubDate>Sun, 16 Aug 2026 01:10:04 +0000</pubDate>
      <link>https://dev.to/shakirriyaz/add-human-approval-to-a-langchain-agent-in-5-minutes-281d</link>
      <guid>https://dev.to/shakirriyaz/add-human-approval-to-a-langchain-agent-in-5-minutes-281d</guid>
      <description>&lt;h1&gt;
  
  
  Add human approval to a LangChain agent in 5 minutes
&lt;/h1&gt;

&lt;p&gt;Your agent can send the email. That's the whole problem.&lt;/p&gt;

&lt;p&gt;A month ago it could only draft one. Now it has a tool that actually calls the API, and somewhere between "draft" and "send" there used to be a person reading it first. If you've given an agent a tool that does something real — sends an email, deploys code, deletes records, moves money — you've probably already felt the gap between "the agent can do this" and "I actually want it to do this unsupervised."&lt;/p&gt;

&lt;p&gt;LangGraph gets you halfway there. Its &lt;code&gt;interrupt()&lt;/code&gt; primitive is the right idea: pause execution, wait for a human, resume. But &lt;code&gt;interrupt()&lt;/code&gt; gives you a pause — not a Slack message with the context in it, not a reject button with a reason field, not a record of who approved what and when. You still have to build all of that yourself, and it's the kind of infrastructure that's tedious to build right and easy to build wrong.&lt;/p&gt;

&lt;p&gt;This post is the other half: wrapping an existing LangChain tool so it requires a real human decision — in Slack or Microsoft Teams — before it runs, in about five minutes and four lines of new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool you already have
&lt;/h2&gt;

&lt;p&gt;Say you've got a tool like this — nothing special, just a &lt;code&gt;BaseTool&lt;/code&gt; that sends an email:&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;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseTool&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendEmailInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email recipient address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email subject line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email body content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendEmailTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseTool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Send an email to a prospect or customer.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;args_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SendEmailInput&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# your actual email-sending logic
&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;Email sent to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your agent calls this whenever it decides an email needs sending. Today, that's the whole decision — the agent decides, and it happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;useagentgate langchain-agentgate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;agentgate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AgentGate&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_agentgate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApprovalRequiredTool&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;AgentGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ag_your_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-agentgate-instance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;gated_email_tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApprovalRequiredTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;wrapped_tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SendEmailTool&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;gate&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="n"&gt;risk_tier&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout_minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;gated_email_tool&lt;/code&gt; has the same name, the same input schema, the same output type as &lt;code&gt;SendEmailTool&lt;/code&gt; — swap it in anywhere the original tool was going. Your agent's code doesn't change. The prompt doesn't change. The only difference shows up at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens now
&lt;/h2&gt;

&lt;p&gt;When the agent decides to call &lt;code&gt;send_email&lt;/code&gt;, instead of running immediately, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Posts a card to Slack (or Teams) with the action, the arguments, and the risk tier&lt;/li&gt;
&lt;li&gt;Blocks, polling every few seconds&lt;/li&gt;
&lt;li&gt;A human clicks &lt;strong&gt;Approve&lt;/strong&gt; or &lt;strong&gt;Reject&lt;/strong&gt; — reject requires a reason&lt;/li&gt;
&lt;li&gt;If approved, &lt;code&gt;SendEmailTool._run()&lt;/code&gt; executes exactly as before and its return value comes back to the agent, unchanged&lt;/li&gt;
&lt;li&gt;If rejected, the agent gets back &lt;code&gt;"Action 'send_email' was rejected by @sarah: wrong recipient list"&lt;/code&gt; instead of raising — so it can react in the conversation ("looks like that got rejected because...") instead of crashing&lt;/li&gt;
&lt;li&gt;If nobody responds in time, it fails closed — no response is treated as no, never as yes
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&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="n"&gt;agent_executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email the Q4 proposal to john@acme.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything up to the tool call runs exactly as it did before. The pause — and the record of who decided what — is the only thing that's new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why wrap instead of rewrite
&lt;/h2&gt;

&lt;p&gt;The alternative is building this yourself: a place to post the message, a way to identify which agent run is waiting on which click, a poller or a webhook, a rejection-reason modal, somewhere to log the decision so "why did the agent do that" has an answer three weeks later. All of that already exists behind &lt;code&gt;ApprovalRequiredTool&lt;/code&gt; — you're not building an approval system, you're passing one existing tool through a wrapper.&lt;/p&gt;

&lt;p&gt;It also composes. Gate the tools that matter — &lt;code&gt;send_email&lt;/code&gt;, &lt;code&gt;deploy&lt;/code&gt;, &lt;code&gt;delete_customer_data&lt;/code&gt;, &lt;code&gt;transfer_funds&lt;/code&gt; — and leave the read-only ones (&lt;code&gt;search&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;summarize&lt;/code&gt;) alone. Different tools can even get different risk tiers and timeouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;gated_delete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApprovalRequiredTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;wrapped_tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;DeleteRecordsTool&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;gate&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="n"&gt;risk_tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout_minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&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;h2&gt;
  
  
  The other half of the picture
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;interrupt()&lt;/code&gt; is still the right call for pausing a &lt;em&gt;graph&lt;/em&gt; mid-execution when you need to resume exact state later — that's not what this replaces. This is for the narrower, more common case: a specific tool call that shouldn't happen without a specific person saying yes, with the Slack UI, the audit trail, and the timeout handling already built.&lt;/p&gt;

&lt;p&gt;Building with CrewAI instead? &lt;code&gt;crewai-agentgate&lt;/code&gt; is the same wrapper for CrewAI's &lt;code&gt;BaseTool&lt;/code&gt; — same four lines, same behavior.&lt;/p&gt;

&lt;p&gt;Full docs and setup: &lt;a href="https://useagentgate.com/docs" rel="noopener noreferrer"&gt;useagentgate.com/docs&lt;/a&gt;. Free to start, no credit card.&lt;/p&gt;

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