<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://xavidop.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://xavidop.me/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-08-06T09:53:13+00:00</updated><id>https://xavidop.me/feed.xml</id><title type="html">Xavier Portilla Edo</title><subtitle>Personal Blog of Xavier Portilla Edo.
</subtitle><author><name>Xavi Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><entry xml:lang="en"><title type="html">Building Agents in Go: Where Genkit Shines vs ADK 2.0 (English)</title><link href="https://xavidop.me/genkit/2026-08-06-building-agents-genkit-vs-adk-go/" rel="alternate" type="text/html" title="Building Agents in Go: Where Genkit Shines vs ADK 2.0 (English)" /><published>2026-08-06T00:00:00+00:00</published><updated>2026-08-06T09:52:49+00:00</updated><id>https://xavidop.me/genkit/building-agents-genkit-vs-adk-go</id><content type="html" xml:base="https://xavidop.me/genkit/2026-08-06-building-agents-genkit-vs-adk-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-build-a-research-team-you-can-fire-and-forget" id="markdown-toc-the-build-a-research-team-you-can-fire-and-forget">The build: a research team you can fire and forget</a></li>
  <li><a href="#why-this-is-easy-middleware-composes-with-agents" id="markdown-toc-why-this-is-easy-middleware-composes-with-agents">Why this is easy: middleware composes with agents</a>    <ol>
      <li><a href="#cross-vendor-fallback-is-configuration" id="markdown-toc-cross-vendor-fallback-is-configuration">Cross-vendor fallback is configuration</a></li>
      <li><a href="#human-approval-is-configuration-too" id="markdown-toc-human-approval-is-configuration-too">Human approval is configuration too</a></li>
    </ol>
  </li>
  <li><a href="#the-same-build-in-adk-20-piece-by-piece" id="markdown-toc-the-same-build-in-adk-20-piece-by-piece">The same build in ADK 2.0, piece by piece</a></li>
  <li><a href="#the-pieces-in-detail" id="markdown-toc-the-pieces-in-detail">The pieces in detail</a>    <ol>
      <li><a href="#typed-session-state" id="markdown-toc-typed-session-state">Typed session state</a></li>
      <li><a href="#custom-agent-loops" id="markdown-toc-custom-agent-loops">Custom agent loops</a></li>
      <li><a href="#agents-defined-in-prompt-files" id="markdown-toc-agents-defined-in-prompt-files">Agents defined in .prompt files</a></li>
    </ol>
  </li>
  <li><a href="#where-adks-agent-stack-is-ahead" id="markdown-toc-where-adks-agent-stack-is-ahead">Where ADK’s agent stack is ahead</a></li>
  <li><a href="#the-scorecard" id="markdown-toc-the-scorecard">The scorecard</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>In my <a href="/genkit/2026-08-05-genkit-go-vs-adk-go/">previous comparison of Genkit Go and ADK Go 2.0</a> I covered the everyday tasks. This article is about the agent layer, and instead of comparing features one by one, I did what you would actually do at work: <strong>I built something complex in one framework and then tried to build the same thing in the other.</strong></p>

<p>Same rules as always: every program here compiled and ran. <strong>Genkit Go v1.11.0</strong> and <strong>ADK Go v2.1.0</strong>, <code class="language-plaintext highlighter-rouge">gemini-3-flash-preview</code> plus <code class="language-plaintext highlighter-rouge">gpt-5-mini</code>, 2026-08-05. Where I cite internals, they come from the released module sources.</p>

<p>One caveat upfront so it frames everything: Genkit’s agents API sits behind <code class="language-plaintext highlighter-rouge">genkit.WithExperimental()</code> and can change between minor releases, while ADK’s agent stack is GA. That is a real ADK advantage. Everything else in this article is about what you can build.</p>

<h2 id="the-build-a-research-team-you-can-fire-and-forget">The build: a research team you can fire and forget</h2>

<p>Here is the spec. It is deliberately the kind of thing a real product asks for:</p>

<ol>
  <li>An <strong>orchestrator</strong> agent that coordinates a <strong>researcher</strong> and a <strong>writer</strong>.</li>
  <li>The sub-agents save their work as <strong>files</strong> (findings.md, draft.md) that end up in one place.</li>
  <li>The system <strong>survives a model outage</strong>: if the orchestrator’s primary model fails, it falls back to a different vendor.</li>
  <li>Dangerous tools need <strong>human approval</strong> before they run.</li>
  <li>The client <strong>fires the task and disconnects</strong>; the result waits server-side.</li>
</ol>

<p>In Genkit this is <strong>one program of 90 lines</strong>, and it ran on the first try. The interesting part is that requirements 1, 2 and 3 are not code at all. They are three entries in a middleware list:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">orchestrator</span> <span class="o">:=</span> <span class="n">genkitx</span><span class="o">.</span><span class="n">DefineAgent</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"orchestrator"</span><span class="p">,</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">InlinePrompt</span><span class="p">{</span>
        <span class="c">// The primary model is BROKEN on purpose.</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-nonexistent-model"</span><span class="p">),</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithSystem</span><span class="p">(</span><span class="s">"You coordinate a team. First delegate research to the researcher, then delegate a short draft to the writer. Finally reply with a one-sentence status."</span><span class="p">),</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithUse</span><span class="p">(</span>
            <span class="o">&amp;</span><span class="n">middleware</span><span class="o">.</span><span class="n">Fallback</span><span class="p">{</span><span class="n">Models</span><span class="o">:</span> <span class="p">[]</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelRef</span><span class="p">{</span>
                <span class="n">ai</span><span class="o">.</span><span class="n">NewModelRef</span><span class="p">(</span><span class="s">"openai/gpt-5-mini"</span><span class="p">,</span> <span class="no">nil</span><span class="p">),</span>      <span class="c">// req 3: cross-vendor healing</span>
            <span class="p">}},</span>
            <span class="o">&amp;</span><span class="n">middlewarex</span><span class="o">.</span><span class="n">Agents</span><span class="p">{</span>                                <span class="c">// req 1: the team</span>
                <span class="n">Agents</span><span class="o">:</span>           <span class="p">[]</span><span class="n">aix</span><span class="o">.</span><span class="n">AgentRef</span><span class="p">{</span><span class="n">researcher</span><span class="o">.</span><span class="n">Ref</span><span class="p">(),</span> <span class="n">writer</span><span class="o">.</span><span class="n">Ref</span><span class="p">()},</span>
                <span class="n">MaxDelegations</span><span class="o">:</span>   <span class="m">4</span><span class="p">,</span>
                <span class="n">ArtifactStrategy</span><span class="o">:</span> <span class="n">middlewarex</span><span class="o">.</span><span class="n">ArtifactStrategySession</span><span class="p">,</span>
            <span class="p">},</span>
            <span class="o">&amp;</span><span class="n">middlewarex</span><span class="o">.</span><span class="n">Artifacts</span><span class="p">{</span><span class="n">Readonly</span><span class="o">:</span> <span class="no">true</span><span class="p">},</span>             <span class="c">// req 2: read the team's files</span>
        <span class="p">),</span>
    <span class="p">},</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">WithSessionStore</span><span class="p">(</span><span class="n">localstore</span><span class="o">.</span><span class="n">NewInMemorySessionStore</span><span class="p">[</span><span class="n">any</span><span class="p">]()),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The researcher and writer are each ~8 lines: an <code class="language-plaintext highlighter-rouge">InlinePrompt</code> with a Gemini model, a system prompt, and <code class="language-plaintext highlighter-rouge">&amp;middlewarex.Artifacts{}</code> so they get <code class="language-plaintext highlighter-rouge">write_artifact</code> tools. Requirement 5 is the client side:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">conn</span><span class="o">.</span><span class="n">Send</span><span class="p">(</span><span class="o">&amp;</span><span class="n">aix</span><span class="o">.</span><span class="n">AgentInput</span><span class="p">{</span>
    <span class="n">Message</span><span class="o">:</span> <span class="n">ai</span><span class="o">.</span><span class="n">NewUserTextMessage</span><span class="p">(</span><span class="s">"Research why Go suits AI agents, then have the writer produce a short draft."</span><span class="p">),</span>
    <span class="n">Detach</span><span class="o">:</span>  <span class="no">true</span><span class="p">,</span>
<span class="p">})</span>
<span class="n">out</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">conn</span><span class="o">.</span><span class="n">Output</span><span class="p">()</span> <span class="c">// returns immediately</span>
<span class="c">// later, from anywhere:</span>
<span class="n">snap</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">orchestrator</span><span class="o">.</span><span class="n">GetSnapshot</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">out</span><span class="o">.</span><span class="n">SnapshotID</span><span class="p">)</span>
</code></pre></div></div>

<p>And the run, verbatim:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>detached after 1ms, snapshot fc70253d-55f8-4890-a587-a6aa74d69438
settled: completed after 58s
artifact "researcher_1/findings.md" (5041 chars) from researcher
artifact "writer_2/draft.md" (3840 chars) from writer
orchestrator: I delegated the research task to the researcher (findings.md saved)
and the short draft to the writer (draft.md saved). Status: research and draft
are complete and saved as researcher_1/findings.md and writer_2/draft.md.
</code></pre></div></div>

<p>Read that output again, because a lot happened in those 58 seconds while no client was connected: the orchestrator’s broken Gemini model failed on <strong>every turn</strong> and healed onto OpenAI every time, it delegated to two Gemini-powered sub-agents through auto-generated <code class="language-plaintext highlighter-rouge">delegate_to_*</code> tools, their artifacts were merged into the orchestrator’s session with <code class="language-plaintext highlighter-rouge">source</code> metadata, and the whole thing settled into a snapshot I could read later from another process. A multi-vendor agent team, running unattended, in 90 lines.</p>

<h2 id="why-this-is-easy-middleware-composes-with-agents">Why this is easy: middleware composes with agents</h2>

<p>The capstone works because of one architectural decision: in Genkit, a middleware is a unit that can <strong>inject tools, wrap the model call, and wrap tool execution</strong>, and the same unit plugs into a plain <code class="language-plaintext highlighter-rouge">Generate</code> call or an agent’s prompt with the same <code class="language-plaintext highlighter-rouge">ai.WithUse(...)</code> line. Capabilities become list entries.</p>

<p>The shelf in v1.11.0’s <code class="language-plaintext highlighter-rouge">plugins/middleware</code>: <code class="language-plaintext highlighter-rouge">Retry</code>, <code class="language-plaintext highlighter-rouge">Fallback</code>, <code class="language-plaintext highlighter-rouge">ToolApproval</code>, <code class="language-plaintext highlighter-rouge">Filesystem</code> (sandboxed file tools), <code class="language-plaintext highlighter-rouge">Skills</code>, plus the experimental <code class="language-plaintext highlighter-rouge">Agents</code> (delegation) and <code class="language-plaintext highlighter-rouge">Artifacts</code>. Two of them deserve their own demos, both of which I ran.</p>

<h3 id="cross-vendor-fallback-is-configuration">Cross-vendor fallback is configuration</h3>

<p>The plugin ecosystem and the middleware system meet here. Any model from any plugin can be a fallback target for any other, so surviving a vendor outage is this, and nothing else:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">g</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Init</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">genkit</span><span class="o">.</span><span class="n">WithPlugins</span><span class="p">(</span>
    <span class="o">&amp;</span><span class="n">googlegenai</span><span class="o">.</span><span class="n">GoogleAI</span><span class="p">{},</span>
    <span class="o">&amp;</span><span class="n">openai</span><span class="o">.</span><span class="n">OpenAI</span><span class="p">{},</span>
<span class="p">))</span>

<span class="n">text</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">GenerateText</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-nonexistent-model"</span><span class="p">),</span> <span class="c">// broken</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Say hi in three words."</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithUse</span><span class="p">(</span>
        <span class="o">&amp;</span><span class="n">middleware</span><span class="o">.</span><span class="n">Retry</span><span class="p">{</span><span class="n">MaxRetries</span><span class="o">:</span> <span class="m">1</span><span class="p">},</span>
        <span class="o">&amp;</span><span class="n">middleware</span><span class="o">.</span><span class="n">Fallback</span><span class="p">{</span><span class="n">Models</span><span class="o">:</span> <span class="p">[]</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelRef</span><span class="p">{</span>
            <span class="n">ai</span><span class="o">.</span><span class="n">NewModelRef</span><span class="p">(</span><span class="s">"openai/gpt-5-mini"</span><span class="p">,</span> <span class="no">nil</span><span class="p">),</span>
        <span class="p">}},</span>
    <span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>My run: without the middleware, <code class="language-plaintext highlighter-rouge">Error 404, Message: models/gemini-nonexistent-model is not found</code>. With it: <code class="language-plaintext highlighter-rouge">Hello there, friend.</code> from OpenAI. 37 lines total, and the same two lines work inside an agent, which is exactly how the capstone’s orchestrator heals itself.</p>

<h3 id="human-approval-is-configuration-too">Human approval is configuration too</h3>

<p><code class="language-plaintext highlighter-rouge">ToolApproval</code> wraps tool execution and interrupts any tool not on its allowlist. The tool itself knows nothing about approvals:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">resp</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Delete the files in /tmp/scratch please."</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithTools</span><span class="p">(</span><span class="n">deleteFiles</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithUse</span><span class="p">(</span><span class="o">&amp;</span><span class="n">middleware</span><span class="o">.</span><span class="n">ToolApproval</span><span class="p">{}),</span> <span class="c">// nothing pre-approved</span>
<span class="p">)</span>
<span class="c">// resp.FinishReason == "interrupted"</span>

<span class="n">part</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">deleteFiles</span><span class="o">.</span><span class="n">RestartWith</span><span class="p">(</span><span class="n">p</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithResumedMetadata</span><span class="p">[</span><span class="n">DeleteInput</span><span class="p">](</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">any</span><span class="p">{</span><span class="s">"toolApproved"</span><span class="o">:</span> <span class="no">true</span><span class="p">}))</span>
<span class="n">final</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithMessages</span><span class="p">(</span><span class="n">resp</span><span class="o">.</span><span class="n">History</span><span class="p">()</span><span class="o">...</span><span class="p">),</span> <span class="n">ai</span><span class="o">.</span><span class="n">WithToolRestarts</span><span class="p">(</span><span class="n">part</span><span class="p">),</span> <span class="c">/* ... */</span><span class="p">)</span>
</code></pre></div></div>

<p>My run: <code class="language-plaintext highlighter-rouge">finish reason: interrupted</code>, then after approval, <code class="language-plaintext highlighter-rouge">OK. I've deleted the files in /tmp/scratch.</code> Governance became a deployment decision: you can add approval to an existing agent without touching a single tool.</p>

<h2 id="the-same-build-in-adk-20-piece-by-piece">The same build in ADK 2.0, piece by piece</h2>

<p>Now the other side, requirement by requirement, with everything verified against the v2.1.0 sources or actually built.</p>

<p><strong>The team (req 1): ADK has this, GA.</strong> <code class="language-plaintext highlighter-rouge">SubAgents</code> with the auto-injected <code class="language-plaintext highlighter-rouge">transfer_to_agent</code> tool, or <code class="language-plaintext highlighter-rouge">agenttool</code> for function-call delegation. No complaints; this is ADK’s home turf.</p>

<p><strong>Approval (req 4): ADK has this too, GA, and the declaration is even simpler.</strong> <code class="language-plaintext highlighter-rouge">functiontool.Config{RequireConfirmation: true}</code> is one flag. I ran the full round-trip in both frameworks and they land within one line of each other (102 vs 101 lines). The difference is the shape: ADK’s wire flow is a synthetic <code class="language-plaintext highlighter-rouge">adk_request_confirmation</code> function call answered with a <code class="language-plaintext highlighter-rouge">map[string]any{"confirmed": true}</code> payload, while Genkit types the pause payload and the resume payload as Go structs and validates every resume against history. Pick your poison: a flag with an untyped protocol, or a typed protocol you wire yourself.</p>

<p><strong>Artifacts (req 2): partial.</strong> ADK has an artifact <em>service</em> (in-memory or GCS) and a <code class="language-plaintext highlighter-rouge">loadartifactstool</code>. What it does not have is the merging story: sub-agent artifacts flowing into the parent session with source attribution is what <code class="language-plaintext highlighter-rouge">ArtifactStrategySession</code> gave me for free.</p>

<p><strong>Cross-vendor fallback (req 3): you build it yourself, and it bites.</strong> ADK has no middleware and its callbacks cannot wrap the model call, so the seam is writing a custom <code class="language-plaintext highlighter-rouge">model.LLM</code> decorator. I built it: buffer the primary’s output, and on failure call the backup. There is a trap here, and my first version hit it. The backup vendor rejected the call with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"The requested model 'gemini-nonexistent-model' does not exist."
</code></pre></div></div>

<p>ADK’s <code class="language-plaintext highlighter-rouge">LLMRequest</code> carries the model <strong>name inside the request</strong>, so my OpenAI backup was asked to run the Gemini model. The decorator also has to rewrite the request:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">backupReq</span> <span class="o">:=</span> <span class="o">*</span><span class="n">req</span>
<span class="n">backupReq</span><span class="o">.</span><span class="n">Model</span> <span class="o">=</span> <span class="n">f</span><span class="o">.</span><span class="n">backup</span><span class="o">.</span><span class="n">Name</span><span class="p">()</span>
</code></pre></div></div>

<p>After that fix it worked (<code class="language-plaintext highlighter-rouge">Hi there, friend.</code>). Final tally: <strong>86 lines of decorator and ceremony versus two lines of configuration</strong>, and the 86-line version is per-agent, static, one-level-deep, and has to handle mid-stream failure buffering itself. This is what “no middleware” costs in practice.</p>

<p><strong>Fire and forget (req 5): not possible.</strong> The v2.1.0 source is unambiguous. <code class="language-plaintext highlighter-rouge">Runner.Run</code> is a pull-based iterator: nothing executes unless the client consumes events, breaking the loop unwinds the stack, cancelling the context aborts the work, and the REST server passes the request context straight into the run. There is no <code class="language-plaintext highlighter-rouge">context.WithoutCancel</code> anywhere in the runner or agent paths. ADK <em>can</em> park at an interrupt and survive a process restart by replaying the session event log, which is genuinely robust, but pausing at a boundary is not continuing to work after the client leaves.</p>

<p>So the capstone scorecard: two requirements ADK meets as well or better (team, approval flag), one it meets partially (artifacts), one it makes you hand-build with sharp edges (fallback), and one it cannot do (detach). And each of the pieces it does have arrives through a different mechanism: a config field here, a toolset there, a plugin bundle, a hand-written decorator. Genkit’s version is one composition model for all of them.</p>

<h2 id="the-pieces-in-detail">The pieces in detail</h2>

<p>The capstone leaned on agent features that deserve their own numbers. All of these ran, in both frameworks where both have them.</p>

<h3 id="typed-session-state">Typed session state</h3>

<p>A Genkit agent’s state is a Go struct. A tool mutates it, every change streams to the client as a JSON-Patch, and the client reads it back typed:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">GameState</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Score</span> <span class="kt">int</span>      <span class="s">`json:"score"`</span>
    <span class="n">Moves</span> <span class="p">[]</span><span class="kt">string</span> <span class="s">`json:"moves"`</span>
<span class="p">}</span>

<span class="c">// inside a tool</span>
<span class="k">if</span> <span class="n">s</span> <span class="o">:=</span> <span class="n">aix</span><span class="o">.</span><span class="n">SessionFromContext</span><span class="p">[</span><span class="n">GameState</span><span class="p">](</span><span class="n">ctx</span><span class="p">);</span> <span class="n">s</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="n">s</span><span class="o">.</span><span class="n">UpdateCustom</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">st</span> <span class="n">GameState</span><span class="p">)</span> <span class="n">GameState</span> <span class="p">{</span>
        <span class="n">st</span><span class="o">.</span><span class="n">Score</span> <span class="o">+=</span> <span class="n">in</span><span class="o">.</span><span class="n">Points</span>
        <span class="n">st</span><span class="o">.</span><span class="n">Moves</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">st</span><span class="o">.</span><span class="n">Moves</span><span class="p">,</span> <span class="n">in</span><span class="o">.</span><span class="n">Move</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">st</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>My run streamed <code class="language-plaintext highlighter-rouge">[{"op":"replace","path":"","value":{"moves":["e4"],"score":10}}]</code> and <code class="language-plaintext highlighter-rouge">conn.Custom()</code> returned <code class="language-plaintext highlighter-rouge">score=20 moves=[e4 Nf3]</code> as a struct. Prompts read the same state with ``.</p>

<p>ADK’s session state is <code class="language-plaintext highlighter-rouge">Get(string) (any, error)</code> / <code class="language-plaintext highlighter-rouge">Set(string, any)</code>: a map, type assertions at every read, no schema. It works (my run: <code class="language-plaintext highlighter-rouge">score=20 (int)</code>), and instruction strings can template state with <code class="language-plaintext highlighter-rouge">{score}</code>, but nothing checks anything at compile time. The 2.0 graph engine does have typed parameter binding, but only for workflow function nodes, not for session state in a chat agent.</p>

<h3 id="custom-agent-loops">Custom agent loops</h3>

<p>Both frameworks let you write a fully custom agent. In ADK you cannot implement the <code class="language-plaintext highlighter-rouge">agent.Agent</code> interface directly, since it is sealed and the compiler rejects any attempt with <code class="language-plaintext highlighter-rouge">missing method internal</code>; the sanctioned path is <code class="language-plaintext highlighter-rouge">agent.New(agent.Config{Run: ...})</code>, a full execution override, and it ran in my tests.</p>

<p>The difference is what you write. Genkit’s <code class="language-plaintext highlighter-rouge">SessionRunner</code> hands you a turn loop with history, per-turn tracing, snapshot writes and panic recovery built in; my draft-then-self-critique agent is <strong>57 lines</strong>. The ADK version of the same agent is <strong>96 lines</strong>, because a <code class="language-plaintext highlighter-rouge">Run</code> override means producing raw <code class="language-plaintext highlighter-rouge">session.Event</code>s: dig the question out of the event log, call the raw model layer, construct and yield events yourself.</p>

<h3 id="agents-defined-in-prompt-files">Agents defined in .prompt files</h3>

<p><code class="language-plaintext highlighter-rouge">DefinePromptAgent</code> binds an agent to a <code class="language-plaintext highlighter-rouge">.prompt</code> file by name; the model, config and persona are data, not Go:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pirate</span> <span class="o">:=</span> <span class="n">genkitx</span><span class="o">.</span><span class="n">DefinePromptAgent</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"pirate"</span><span class="p">,</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">WithSessionStore</span><span class="p">[</span><span class="n">any</span><span class="p">](</span><span class="n">localstore</span><span class="o">.</span><span class="n">NewInMemorySessionStore</span><span class="p">[</span><span class="n">any</span><span class="p">]()),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>26 lines of Go, and my run answered: <code class="language-plaintext highlighter-rouge">Rewrite 'em in Python only if ye want yer agents movin' as slow as a barnacle-covered scow in a dead calm.</code> Non-engineers edit the persona without touching code, and the same dotprompt format works across Genkit’s other language SDKs. ADK’s equivalent is an <code class="language-plaintext highlighter-rouge">Instruction</code> string or an <code class="language-plaintext highlighter-rouge">InstructionProvider</code> where you load files yourself; its YAML agent-config system exists but lives in an <code class="language-plaintext highlighter-rouge">internal/</code> package only ADK’s own CLI can reach.</p>

<h2 id="where-adks-agent-stack-is-ahead">Where ADK’s agent stack is ahead</h2>

<p>The list I would actually weigh on the other side, verified in the same source dive:</p>

<ul>
  <li><strong>GA stability</strong>, against everything above being on Genkit’s experimental track.</li>
  <li><strong>The graph engine as the runtime</strong>: every ADK agent runs as a node in the workflow engine, and the node kit (typed function nodes, routing, per-node schema validation, retries, nested graphs) is public API.</li>
  <li><strong>Schema-validated human input in workflows</strong>: a graph node can declare a JSON schema for the human’s answer, validated on resume. For form-like HITL inside a pipeline, that is ahead of anything Genkit ships.</li>
  <li><strong>Task isolation</strong>: <code class="language-plaintext highlighter-rouge">ModeTask</code> sub-agents get their own conversation scope with an auto-injected <code class="language-plaintext highlighter-rouge">finish_task</code> tool and scope-filtered history.</li>
  <li><strong>Live audio</strong>: <code class="language-plaintext highlighter-rouge">RunLive</code> with modalities, speech config and transcription has no Genkit Go equivalent.</li>
</ul>

<h2 id="the-scorecard">The scorecard</h2>

<p>Non-blank lines including imports. Every program ran.</p>

<table>
  <thead>
    <tr>
      <th>Experiment</th>
      <th>Genkit Go</th>
      <th>ADK Go 2.0</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>The capstone: team + artifacts + fallback + detach</td>
      <td>90</td>
      <td>not buildable (fallback DIY, detach impossible)</td>
    </tr>
    <tr>
      <td>Cross-vendor fallback</td>
      <td>37 (configuration)</td>
      <td>86 (hand-written decorator, request-rewrite gotcha)</td>
    </tr>
    <tr>
      <td>Approval on an existing tool</td>
      <td>45 (middleware config)</td>
      <td>flag exists (GA), untyped wire protocol</td>
    </tr>
    <tr>
      <td>Typed state, 2 turns, live patches</td>
      <td>80, typed</td>
      <td>96, map + casts</td>
    </tr>
    <tr>
      <td>Custom draft-critique agent</td>
      <td>57</td>
      <td>96</td>
    </tr>
    <tr>
      <td>Agent from a .prompt file</td>
      <td>26</td>
      <td>no equivalent</td>
    </tr>
    <tr>
      <td>Undo the last exchange</td>
      <td>41</td>
      <td>no API: append-only event log</td>
    </tr>
    <tr>
      <td>Context caching</td>
      <td>40, one method call</td>
      <td>lifecycle DIY with a raw genai client</td>
    </tr>
  </tbody>
</table>

<h2 id="conclusion">Conclusion</h2>

<p>The agent layer is where Genkit’s two architectural bets pay off together. The plugin ecosystem means models from different vendors, local runtimes and vector stores are interchangeable references. The middleware system means capabilities (delegation, artifacts, fallback, retries, approval, sandboxed files, skills) are composable list entries that work identically in a one-shot generate and inside an agent. Stack those two on top of typed sessions, snapshots and detach, and a fire-and-forget multi-vendor agent team is a 90-line program. I know because I ran it, and I also know what the same spec costs in ADK because I built the pieces: one requirement hand-written with real sharp edges, and one that cannot be built at all.</p>

<p>ADK’s agent stack remains the right pick when its strengths are your requirements: GA guarantees, graph workflows with schema validation, task isolation, live audio, and the Google Cloud platform around it. But if the question is “how hard is it to build something complex”, the answer I measured is: in Genkit, complexity composes; in ADK, each capability is its own project.</p>

<p>Further reading:</p>

<ul>
  <li><a href="/genkit/2026-08-05-genkit-go-vs-adk-go/">Genkit Go vs ADK Go 2.0: a Hands-On Look at Boilerplate and Low-Level Control</a></li>
  <li><a href="https://github.com/genkit-ai/genkit/tree/main/go/samples/basic-agents">Genkit Go basic-agents sample</a></li>
  <li><a href="https://genkit.dev/docs/js/middleware/">Genkit middleware documentation</a></li>
  <li><a href="https://adk.dev/2.0/">ADK 2.0 release notes</a></li>
  <li><a href="https://github.com/genkit-ai/genkit">Genkit GitHub repository</a></li>
  <li><a href="https://github.com/google/adk-go">ADK Go GitHub repository</a></li>
</ul>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="genkit" /><category term="genkit" /><category term="adk" /><category term="golang" /><category term="agents" /><summary type="html"><![CDATA[A companion to my Genkit Go vs ADK Go 2.0 comparison, this time only about agents. I built a fire-and-forget research team in 90 lines of Genkit: an orchestrator with two sub-agents, artifacts, cross-vendor model fallback and human approval, then tried to build the same thing in ADK. Middleware and the plugin ecosystem are what make the difference, and every program in this article ran.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/building-agents-genkit-vs-adk-go.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/building-agents-genkit-vs-adk-go.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Genkit Go vs ADK Go 2.0: a Hands-On Look at Boilerplate and Low-Level Control (English)</title><link href="https://xavidop.me/genkit/2026-08-05-genkit-go-vs-adk-go/" rel="alternate" type="text/html" title="Genkit Go vs ADK Go 2.0: a Hands-On Look at Boilerplate and Low-Level Control (English)" /><published>2026-08-05T00:00:00+00:00</published><updated>2026-08-05T15:30:06+00:00</updated><id>https://xavidop.me/genkit/genkit-go-vs-adk-go</id><content type="html" xml:base="https://xavidop.me/genkit/2026-08-05-genkit-go-vs-adk-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#tldr" id="markdown-toc-tldr">TL;DR</a></li>
  <li><a href="#the-mental-model" id="markdown-toc-the-mental-model">The mental model</a>    <ol>
      <li><a href="#genkit-the-model-call-is-the-unit" id="markdown-toc-genkit-the-model-call-is-the-unit">Genkit: the model call is the unit</a></li>
      <li><a href="#adk-the-agent-is-the-unit" id="markdown-toc-adk-the-agent-is-the-unit">ADK: the agent is the unit</a></li>
    </ol>
  </li>
  <li><a href="#structured-output-where-the-gap-is-widest" id="markdown-toc-structured-output-where-the-gap-is-widest">Structured output: where the gap is widest</a>    <ol>
      <li><a href="#genkit" id="markdown-toc-genkit">Genkit</a></li>
      <li><a href="#adk" id="markdown-toc-adk">ADK</a></li>
    </ol>
  </li>
  <li><a href="#streaming-deltas-vs-partial-events" id="markdown-toc-streaming-deltas-vs-partial-events">Streaming: deltas vs partial events</a></li>
  <li><a href="#tool-calling-closer-than-i-expected" id="markdown-toc-tool-calling-closer-than-i-expected">Tool calling: closer than I expected</a></li>
  <li><a href="#getting-low-level" id="markdown-toc-getting-low-level">Getting low-level</a>    <ol>
      <li><a href="#wrapping-the-model-call" id="markdown-toc-wrapping-the-model-call">Wrapping the model call</a></li>
      <li><a href="#custom-model-backends-local-gemma-on-ollama" id="markdown-toc-custom-model-backends-local-gemma-on-ollama">Custom model backends: local Gemma on Ollama</a></li>
      <li><a href="#and-when-there-is-no-plugin-at-all" id="markdown-toc-and-when-there-is-no-plugin-at-all">And when there is no plugin at all</a></li>
      <li><a href="#the-raw-call-escape-hatch" id="markdown-toc-the-raw-call-escape-hatch">The raw call escape hatch</a></li>
    </ol>
  </li>
  <li><a href="#where-adks-ceremony-pays-off" id="markdown-toc-where-adks-ceremony-pays-off">Where ADK’s ceremony pays off</a></li>
  <li><a href="#the-plugin-ecosystems" id="markdown-toc-the-plugin-ecosystems">The plugin ecosystems</a></li>
  <li><a href="#the-scorecard" id="markdown-toc-the-scorecard">The scorecard</a></li>
  <li><a href="#a-concrete-decision-guide" id="markdown-toc-a-concrete-decision-guide">A concrete decision guide</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p><strong>Genkit</strong> and <strong>ADK (Agent Development Kit)</strong> are both Google-backed Gen AI frameworks with first-class Go SDKs, and I keep getting the same question: <em>what is actually different when you sit down and write the code?</em></p>

<p>This is not a “which one is better” article. They point at different problems: ADK is an <strong>agent orchestration framework</strong> (agents, sessions, multi-agent transfer, and since 2.0 a graph-based workflow engine), while Genkit is a <strong>Gen AI application framework</strong> (model calls, flows, structured output, middleware). But they overlap on the everyday tasks every Go developer does (call a model, get typed output, stream, add tools, intercept requests), and on those tasks the developer experience diverges a lot.</p>

<p>So instead of comparing docs, I wrote the same eight programs twice and ran every single one against a live Gemini backend:</p>

<ol>
  <li>One prompt → one answer</li>
  <li>A raw model call with no framework machinery</li>
  <li>Structured output into a Go struct</li>
  <li>Streaming</li>
  <li>Tool calling</li>
  <li>Intercepting / wrapping the model call</li>
  <li>A custom model backend</li>
  <li>Multi-turn conversation and HTTP serving</li>
</ol>

<p>Everything below compiled and ran on <strong>Genkit Go v1.11.0</strong> and <strong>ADK Go v2.1.0</strong> (the <code class="language-plaintext highlighter-rouge">google.golang.org/adk/v2</code> module, GA since June 30, 2026) with <code class="language-plaintext highlighter-rouge">gemini-3-flash-preview</code>, on 2026-08-05. Where I quote framework internals, they come from those released versions, not from the main branch. Nothing in this article is speculated from documentation.</p>

<h2 id="tldr">TL;DR</h2>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Genkit Go</th>
      <th>ADK Go 2.0</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>One prompt → text</td>
      <td><code class="language-plaintext highlighter-rouge">genkit.GenerateText(...)</code>: 21 lines, 2 error paths</td>
      <td>model + agent + runner + session + event loop: 49 lines, 8 error paths</td>
    </tr>
    <tr>
      <td>Structured output</td>
      <td><code class="language-plaintext highlighter-rouge">genkit.GenerateData[Recipe](...)</code>, schema inferred from struct tags, typed result</td>
      <td>hand-written <code class="language-plaintext highlighter-rouge">genai.Schema</code> tree, raw JSON text back, unmarshal it yourself</td>
    </tr>
    <tr>
      <td>Streaming</td>
      <td>callback receives deltas, final response is ready-assembled</td>
      <td>SSE partial events; the final event <strong>repeats the full text</strong>, you deduplicate by hand</td>
    </tr>
    <tr>
      <td>Tools</td>
      <td>typed Go func, loop runs inside <code class="language-plaintext highlighter-rouge">Generate</code></td>
      <td>typed Go func (genuinely elegant), but agent/runner/session around it</td>
    </tr>
    <tr>
      <td>Wrap the model call</td>
      <td><code class="language-plaintext highlighter-rouge">WithUse</code> middleware with a real <code class="language-plaintext highlighter-rouge">next()</code>, applied per call</td>
      <td>fixed-signature callbacks (no wrapping) or decorate the <code class="language-plaintext highlighter-rouge">model.LLM</code> interface, per agent</td>
    </tr>
    <tr>
      <td>Local models (Ollama)</td>
      <td>official plugin, first-class provider</td>
      <td>no native support; OpenAI-compatible adapter with <code class="language-plaintext highlighter-rouge">BaseURL</code></td>
    </tr>
    <tr>
      <td>Custom model backend</td>
      <td>one function, works instantly with <code class="language-plaintext highlighter-rouge">Generate</code></td>
      <td>two-method interface (the smallest contract in either SDK), but invoking it needs the full stack</td>
    </tr>
    <tr>
      <td>Multi-turn</td>
      <td>automatic via agents + session stores (experimental track); <code class="language-plaintext highlighter-rouge">resp.History()</code> for plain generate calls</td>
      <td>automatic via the GA session services</td>
    </tr>
    <tr>
      <td>Multi-agent</td>
      <td>delegation via the <code class="language-plaintext highlighter-rouge">Agents</code> middleware (experimental track)</td>
      <td>first-class: sub-agents, transfer, workflow graph engine</td>
    </tr>
    <tr>
      <td>Serve over HTTP</td>
      <td><code class="language-plaintext highlighter-rouge">genkit.Handler(flow)</code> on plain <code class="language-plaintext highlighter-rouge">net/http</code></td>
      <td>full REST platform API (sessions, SSE, events) + embedded Web UI</td>
    </tr>
    <tr>
      <td>Ecosystem</td>
      <td>16 Go plugins: multi-vendor models, vector stores, telemetry, MCP</td>
      <td>Google Cloud integrations (Vertex, GCS, BigQuery, Apigee, Cloud Run) + OpenAI-compatible + MCP</td>
    </tr>
  </tbody>
</table>

<p>The pattern that emerged: <strong>Genkit’s primitive is the model call; ADK’s primitive is the agent.</strong> When the thing you need <em>is</em> a model call, Genkit hands it to you directly and ADK makes you rent the whole agent apparatus. When the thing you need <em>is</em> a long-lived, multi-agent system, ADK’s apparatus stops being boilerplate and starts being the product.</p>

<h2 id="the-mental-model">The mental model</h2>

<h3 id="genkit-the-model-call-is-the-unit">Genkit: the model call is the unit</h3>

<p>Genkit Go has exactly one setup object (<code class="language-plaintext highlighter-rouge">*genkit.Genkit</code>) and everything else is a package-level function call:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">g</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Init</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">genkit</span><span class="o">.</span><span class="n">WithPlugins</span><span class="p">(</span><span class="o">&amp;</span><span class="n">googlegenai</span><span class="o">.</span><span class="n">GoogleAI</span><span class="p">{}))</span>

<span class="n">text</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">GenerateText</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Why is Go a great language for building AI agents? One short sentence."</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>That is the complete program (plus imports and an error check). <code class="language-plaintext highlighter-rouge">Init</code> doesn’t even return an error: configuration problems panic at startup, and from there on there is a single <code class="language-plaintext highlighter-rouge">err</code> to handle per generation. My runnable version is <strong>21 non-blank lines with 2 error-handling lines</strong>.</p>

<h3 id="adk-the-agent-is-the-unit">ADK: the agent is the unit</h3>

<p>ADK has no “just generate” entry point at the framework level. The unit of execution is an agent, and an agent runs inside a runner, and a runner requires a session service. This is the minimal equivalent program:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">model</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">gemini</span><span class="o">.</span><span class="n">NewModel</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"gemini-3-flash-preview"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">genai</span><span class="o">.</span><span class="n">ClientConfig</span><span class="p">{</span>
    <span class="n">APIKey</span><span class="o">:</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"GOOGLE_API_KEY"</span><span class="p">),</span>
<span class="p">})</span>
<span class="c">// if err != nil ...</span>

<span class="n">a</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">llmagent</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">llmagent</span><span class="o">.</span><span class="n">Config</span><span class="p">{</span>
    <span class="n">Name</span><span class="o">:</span>        <span class="s">"assistant"</span><span class="p">,</span>
    <span class="n">Model</span><span class="o">:</span>       <span class="n">model</span><span class="p">,</span>
    <span class="n">Instruction</span><span class="o">:</span> <span class="s">"You are a helpful assistant."</span><span class="p">,</span>
<span class="p">})</span>
<span class="c">// if err != nil ...</span>

<span class="n">r</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">runner</span><span class="o">.</span><span class="n">NewInMemory</span><span class="p">(</span><span class="s">"hello-app"</span><span class="p">,</span> <span class="n">a</span><span class="p">)</span> <span class="c">// in-memory session/artifact/memory services</span>
<span class="c">// if err != nil ...</span>

<span class="n">msg</span> <span class="o">:=</span> <span class="n">genai</span><span class="o">.</span><span class="n">NewContentFromText</span><span class="p">(</span><span class="s">"Why is Go a great language for building AI agents? One short sentence."</span><span class="p">,</span> <span class="n">genai</span><span class="o">.</span><span class="n">RoleUser</span><span class="p">)</span>

<span class="k">var</span> <span class="n">text</span> <span class="kt">string</span>
<span class="k">for</span> <span class="n">event</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">r</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"user-1"</span><span class="p">,</span> <span class="s">"session-1"</span><span class="p">,</span> <span class="n">msg</span><span class="p">,</span> <span class="n">agent</span><span class="o">.</span><span class="n">RunConfig</span><span class="p">{})</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="c">/* ... */</span> <span class="p">}</span>
    <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">LLMResponse</span><span class="o">.</span><span class="n">Content</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">continue</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">IsFinalResponse</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">part</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">event</span><span class="o">.</span><span class="n">LLMResponse</span><span class="o">.</span><span class="n">Content</span><span class="o">.</span><span class="n">Parts</span> <span class="p">{</span>
            <span class="n">text</span> <span class="o">+=</span> <span class="n">part</span><span class="o">.</span><span class="n">Text</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>49 non-blank lines, 8 error-handling lines</strong> for the same output. And to be fair, <code class="language-plaintext highlighter-rouge">runner.NewInMemory</code> is already ADK being nice: the explicit form makes you wire <code class="language-plaintext highlighter-rouge">runner.Config{AppName, Agent, SessionService, AutoCreateSession}</code> yourself.</p>

<p>Count the concepts you must hold for one prompt: a model, an agent with a <code class="language-plaintext highlighter-rouge">Name</code> (mandatory), an app name, a user ID, a session ID (both meaningless for a one-shot call, both mandatory positional arguments), a <code class="language-plaintext highlighter-rouge">RunConfig</code>, an event iterator, an <code class="language-plaintext highlighter-rouge">IsFinalResponse()</code> predicate, and manual concatenation of <code class="language-plaintext highlighter-rouge">Content.Parts</code>. There is <strong>no <code class="language-plaintext highlighter-rouge">.Text()</code> helper on events</strong>. Even ADK’s own console launcher spends ~45 lines turning events into printable text (<code class="language-plaintext highlighter-rouge">cmd/launcher/console/console.go</code>), because every consumer re-implements this loop.</p>

<p>None of this is <em>bad</em> design: sessions, users and apps are exactly the right vocabulary for a deployed multi-agent platform. It is simply the wrong altitude when what you wanted was an answer to a prompt, and there is no lower altitude offered (almost; see the escape hatch below).</p>

<h2 id="structured-output-where-the-gap-is-widest">Structured output: where the gap is widest</h2>

<p>This was the experiment with the biggest difference, and the one where I initially got ADK <em>wrong</em> in an instructive way.</p>

<h3 id="genkit">Genkit</h3>

<p>You describe the shape as a Go type. The JSON Schema is inferred from the struct (via reflection over <code class="language-plaintext highlighter-rouge">json</code>/<code class="language-plaintext highlighter-rouge">jsonschema</code> tags), constrained decoding is enabled by default, and you get the struct back, parsed and typed:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Recipe</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Name</span>        <span class="kt">string</span>   <span class="s">`json:"name"`</span>
    <span class="n">PrepMinutes</span> <span class="kt">int</span>      <span class="s">`json:"prep_minutes"`</span>
    <span class="n">Ingredients</span> <span class="p">[]</span><span class="kt">string</span> <span class="s">`json:"ingredients"`</span>
<span class="p">}</span>

<span class="n">recipe</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">GenerateData</span><span class="p">[</span><span class="n">Recipe</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithSystem</span><span class="p">(</span><span class="s">"You generate recipes."</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"A simple tortilla de patatas recipe"</span><span class="p">),</span>
<span class="p">)</span>

<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">recipe</span><span class="o">.</span><span class="n">Name</span><span class="p">,</span> <span class="n">recipe</span><span class="o">.</span><span class="n">PrepMinutes</span><span class="p">,</span> <span class="n">recipe</span><span class="o">.</span><span class="n">Ingredients</span><span class="p">)</span>
</code></pre></div></div>

<p>Output from my run:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Classic Spanish Tortilla de Patatas (15 min): 5 ingredients
</code></pre></div></div>

<p>28 lines total. The schema definition <strong>is</strong> the Go type.</p>

<h3 id="adk">ADK</h3>

<p>ADK’s <code class="language-plaintext highlighter-rouge">llmagent.Config.OutputSchema</code> takes a <code class="language-plaintext highlighter-rouge">*genai.Schema</code>: a hand-built tree, field by field, with a hand-maintained <code class="language-plaintext highlighter-rouge">Required</code> list. There is no “infer from struct” path for agent output (curiously, ADK <em>does</em> have schema inference, but only for tool inputs):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">recipeSchema</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">genai</span><span class="o">.</span><span class="n">Schema</span><span class="p">{</span>
    <span class="n">Type</span><span class="o">:</span> <span class="n">genai</span><span class="o">.</span><span class="n">TypeObject</span><span class="p">,</span>
    <span class="n">Properties</span><span class="o">:</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="o">*</span><span class="n">genai</span><span class="o">.</span><span class="n">Schema</span><span class="p">{</span>
        <span class="s">"name"</span><span class="o">:</span>         <span class="p">{</span><span class="n">Type</span><span class="o">:</span> <span class="n">genai</span><span class="o">.</span><span class="n">TypeString</span><span class="p">},</span>
        <span class="s">"prep_minutes"</span><span class="o">:</span> <span class="p">{</span><span class="n">Type</span><span class="o">:</span> <span class="n">genai</span><span class="o">.</span><span class="n">TypeInteger</span><span class="p">},</span>
        <span class="s">"ingredients"</span><span class="o">:</span>  <span class="p">{</span><span class="n">Type</span><span class="o">:</span> <span class="n">genai</span><span class="o">.</span><span class="n">TypeArray</span><span class="p">,</span> <span class="n">Items</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">genai</span><span class="o">.</span><span class="n">Schema</span><span class="p">{</span><span class="n">Type</span><span class="o">:</span> <span class="n">genai</span><span class="o">.</span><span class="n">TypeString</span><span class="p">}},</span>
    <span class="p">},</span>
    <span class="n">Required</span><span class="o">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">"name"</span><span class="p">,</span> <span class="s">"prep_minutes"</span><span class="p">,</span> <span class="s">"ingredients"</span><span class="p">},</span>
<span class="p">}</span>

<span class="n">a</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">llmagent</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">llmagent</span><span class="o">.</span><span class="n">Config</span><span class="p">{</span>
    <span class="n">Name</span><span class="o">:</span>         <span class="s">"chef"</span><span class="p">,</span>
    <span class="n">Model</span><span class="o">:</span>        <span class="n">model</span><span class="p">,</span>
    <span class="n">Instruction</span><span class="o">:</span>  <span class="s">"You generate recipes."</span><span class="p">,</span>
    <span class="n">OutputSchema</span><span class="o">:</span> <span class="n">recipeSchema</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>The schema enforcement worked perfectly in my runs: Gemini returned valid JSON every time. The interesting part is what happens next. My first attempt read the event’s <code class="language-plaintext highlighter-rouge">Output</code> field, which looks exactly like what you want. It was always <code class="language-plaintext highlighter-rouge">nil</code>.</p>

<p>Digging into the released v2.1.0 source: the LlmAgent <em>does</em> parse the structured output internally, and <em>does</em> attach it to the event, but the runner <strong>deliberately strips it before the event reaches you</strong>. In <code class="language-plaintext highlighter-rouge">runner/runner.go</code>, every non-partial agent reply gets cloned with <code class="language-plaintext highlighter-rouge">clone.Output = nil</code>. The parsed value exists to feed ADK 2.0’s graph workflow engine (typed routing between nodes), not for you. As a consumer of <code class="language-plaintext highlighter-rouge">Runner.Run</code>, you receive raw JSON as text and parse it yourself:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">jsonText</span> <span class="kt">string</span>
<span class="k">for</span> <span class="n">event</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">r</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"user-1"</span><span class="p">,</span> <span class="s">"session-1"</span><span class="p">,</span> <span class="n">msg</span><span class="p">,</span> <span class="n">agent</span><span class="o">.</span><span class="n">RunConfig</span><span class="p">{})</span> <span class="p">{</span>
    <span class="c">// ... error handling, nil checks, IsFinalResponse, Parts loop ...</span>
    <span class="n">jsonText</span> <span class="o">+=</span> <span class="n">part</span><span class="o">.</span><span class="n">Text</span>
<span class="p">}</span>

<span class="k">var</span> <span class="n">recipe</span> <span class="n">Recipe</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">jsonText</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">recipe</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="c">/* ... */</span> <span class="p">}</span>
</code></pre></div></div>

<p>(If you set <code class="language-plaintext highlighter-rouge">OutputKey</code>, the same <strong>raw string</strong> is also copied into session state, still not parsed for you.)</p>

<p>Two more sharp edges I hit in the source while verifying this:</p>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">OutputSchema</code> doc comment in v2.1.0 still warns: <em>“when this is set, agent can only reply and cannot use any tools, such as function tools, RAGs, agent transfer, etc.”</em> The implementation has grown workarounds (on some backends it injects a synthetic <code class="language-plaintext highlighter-rouge">set_model_response</code> tool; on Vertex AI with Gemini ≥2.0 it can use native constrained output alongside tools), which means combining tools and output schema behaves <strong>differently depending on backend and model name</strong>, silently.</li>
  <li>Final tally: <strong>68 lines vs Genkit’s 28</strong>, and the typed half of “typed output” is entirely yours to write.</li>
</ul>

<h2 id="streaming-deltas-vs-partial-events">Streaming: deltas vs partial events</h2>

<p>Both frameworks stream. The difference is who assembles the result.</p>

<p><strong>Genkit</strong> gives you a callback that receives <em>deltas</em>, and the final response arrives already assembled:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Count from one to ten in words, one per line."</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithStreaming</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">chunk</span> <span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelResponseChunk</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
        <span class="n">fmt</span><span class="o">.</span><span class="n">Print</span><span class="p">(</span><span class="n">chunk</span><span class="o">.</span><span class="n">Text</span><span class="p">())</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>My run printed <code class="language-plaintext highlighter-rouge">streamed chars: 48, final response chars: 48</code>. Chunks and final response account for the same text exactly once each.</p>

<p><strong>ADK</strong> streams by setting <code class="language-plaintext highlighter-rouge">agent.RunConfig{StreamingMode: agent.StreamingModeSSE}</code> and then flags chunk events with <code class="language-plaintext highlighter-rouge">LLMResponse.Partial</code>. The gotcha: after all the partial events, ADK emits a final non-partial event that <strong>repeats the entire text</strong>. My run: <code class="language-plaintext highlighter-rouge">partial (streamed) chars: 48, final event chars: 48</code>. Print events naively and you output everything twice. Every ADK consumer must carry this filter:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">event</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">r</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"user-1"</span><span class="p">,</span> <span class="s">"session-1"</span><span class="p">,</span> <span class="n">msg</span><span class="p">,</span> <span class="n">agent</span><span class="o">.</span><span class="n">RunConfig</span><span class="p">{</span>
    <span class="n">StreamingMode</span><span class="o">:</span> <span class="n">agent</span><span class="o">.</span><span class="n">StreamingModeSSE</span><span class="p">,</span>
<span class="p">})</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">LLMResponse</span><span class="o">.</span><span class="n">Partial</span> <span class="p">{</span>
        <span class="n">fmt</span><span class="o">.</span><span class="n">Print</span><span class="p">(</span><span class="n">part</span><span class="o">.</span><span class="n">Text</span><span class="p">)</span> <span class="c">// the streamed deltas</span>
    <span class="p">}</span>
    <span class="c">// the final event repeats the full text: skip it, or you print it twice</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This isn’t hypothetical: ADK’s own example code and console launcher both contain hand-rolled deduplication (<code class="language-plaintext highlighter-rouge">if text != prevText</code> in <code class="language-plaintext highlighter-rouge">cmd/launcher/console/console.go</code>). It’s a small thing, but it’s a small thing <em>every single ADK streaming consumer</em> writes, and no Genkit consumer does. As a bonus, Genkit’s <code class="language-plaintext highlighter-rouge">GenerateDataStream[T]</code> even streams <em>partially-parsed typed structs</em> (your <code class="language-plaintext highlighter-rouge">Recipe</code> filling in field by field), which has no ADK equivalent.</p>

<h2 id="tool-calling-closer-than-i-expected">Tool calling: closer than I expected</h2>

<p>Here I expected a bigger gap and didn’t find one: ADK’s function tools are genuinely well designed. Both frameworks let a tool be an ordinary typed Go function, with the JSON Schema inferred from the input struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Genkit</span>
<span class="n">weather</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">DefineTool</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"getWeather"</span><span class="p">,</span> <span class="s">"Gets the current weather for a city"</span><span class="p">,</span>
    <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ToolContext</span><span class="p">,</span> <span class="n">in</span> <span class="n">WeatherInput</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"22C and sunny in %s"</span><span class="p">,</span> <span class="n">in</span><span class="o">.</span><span class="n">City</span><span class="p">),</span> <span class="no">nil</span>
    <span class="p">})</span>

<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"What is the weather in Valencia right now?"</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithTools</span><span class="p">(</span><span class="n">weather</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// ADK: also nice! Generics, schema inferred, type params never spelled out.</span>
<span class="n">weatherTool</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">functiontool</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">functiontool</span><span class="o">.</span><span class="n">Config</span><span class="p">{</span>
    <span class="n">Name</span><span class="o">:</span>        <span class="s">"get_weather"</span><span class="p">,</span>
    <span class="n">Description</span><span class="o">:</span> <span class="s">"Gets the current weather for a city"</span><span class="p">,</span>
<span class="p">},</span> <span class="k">func</span><span class="p">(</span><span class="n">_</span> <span class="n">agent</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">in</span> <span class="n">WeatherInput</span><span class="p">)</span> <span class="p">(</span><span class="n">WeatherOutput</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">WeatherOutput</span><span class="p">{</span><span class="n">Report</span><span class="o">:</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"22C and sunny in %s"</span><span class="p">,</span> <span class="n">in</span><span class="o">.</span><span class="n">City</span><span class="p">)},</span> <span class="no">nil</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Both ran the tool and produced the right answer. The differences are around the edges:</p>

<ul>
  <li><strong>Ceremony</strong>: the Genkit program is 29 lines; the ADK one is 66, because the tool still needs the agent/runner/session stack around it.</li>
  <li><strong>Where the loop lives</strong>: Genkit’s tool loop runs <em>inside</em> <code class="language-plaintext highlighter-rouge">Generate</code> (with parallel tool execution and a <code class="language-plaintext highlighter-rouge">WithMaxTurns</code> bound), so tools work in a plain one-shot call. In ADK the loop belongs to the agent flow, which is consistent with its philosophy, but it means there is no way to use ADK tools without an agent.</li>
  <li><strong>A public-API gotcha</strong>: ADK’s public <code class="language-plaintext highlighter-rouge">tool.Tool</code> interface is only <code class="language-plaintext highlighter-rouge">Name()/Description()/IsLongRunning()</code>. The <em>executable</em> contract (<code class="language-plaintext highlighter-rouge">Declaration()</code>, <code class="language-plaintext highlighter-rouge">Run()</code>) lives in an <code class="language-plaintext highlighter-rouge">internal/</code> package you cannot reference, so you can’t hand-implement a runnable tool against a public interface; you go through <code class="language-plaintext highlighter-rouge">functiontool.New</code>. Genkit’s equivalent (<code class="language-plaintext highlighter-rouge">ai.ToolFunc</code> + <code class="language-plaintext highlighter-rouge">DefineTool</code>) is fully public.</li>
  <li><strong>Composability limit</strong>: ADK documents (in its own <code class="language-plaintext highlighter-rouge">examples/tools/multipletools</code>) that you cannot mix built-in Gemini tools like <code class="language-plaintext highlighter-rouge">geminitool.GoogleSearch{}</code> with custom function tools on one agent; the workaround is wrapping each in a sub-agent.</li>
  <li>Genkit also has <strong>tool interrupts/resume</strong> as a stable API (pause generation from inside a tool, get an <code class="language-plaintext highlighter-rouge">Interrupts()</code> list, resume with <code class="language-plaintext highlighter-rouge">RespondWith</code>/<code class="language-plaintext highlighter-rouge">RestartWith</code>). ADK’s equivalents are tool confirmation flows wired through its HITL machinery.</li>
</ul>

<h2 id="getting-low-level">Getting low-level</h2>

<p>You can go deeper in Genkit than in ADK, and its middleware system is the reason: Genkit exposes the model call, the tool executions and the whole generation loop as wrappable layers, while ADK exposes fixed callback points and one wrappable interface. Here is what that means in code.</p>

<h3 id="wrapping-the-model-call">Wrapping the model call</h3>

<p><strong>Genkit</strong> middleware is a real onion: you get the request <em>and</em> a <code class="language-plaintext highlighter-rouge">next()</code> you control. Timing, retry, caching and request rewriting are all one closure, applied per call:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">timing</span> <span class="o">:=</span> <span class="n">ai</span><span class="o">.</span><span class="n">MiddlewareFunc</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">Hooks</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">ai</span><span class="o">.</span><span class="n">Hooks</span><span class="p">{</span>
        <span class="n">WrapModel</span><span class="o">:</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">params</span> <span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelParams</span><span class="p">,</span> <span class="n">next</span> <span class="n">ai</span><span class="o">.</span><span class="n">ModelNext</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelResponse</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">start</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
            <span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">next</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">params</span><span class="p">)</span>
            <span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"model call: %d messages in, took %s"</span><span class="p">,</span>
                <span class="nb">len</span><span class="p">(</span><span class="n">params</span><span class="o">.</span><span class="n">Request</span><span class="o">.</span><span class="n">Messages</span><span class="p">),</span> <span class="n">time</span><span class="o">.</span><span class="n">Since</span><span class="p">(</span><span class="n">start</span><span class="p">))</span>
            <span class="k">return</span> <span class="n">resp</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">},</span>
    <span class="p">},</span> <span class="no">nil</span>
<span class="p">})</span>

<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span> <span class="c">/* ... */</span><span class="p">,</span> <span class="n">ai</span><span class="o">.</span><span class="n">WithUse</span><span class="p">(</span><span class="n">timing</span><span class="p">))</span>
</code></pre></div></div>

<p>Ran, printed <code class="language-plaintext highlighter-rouge">model call: 1 messages in, took 3.323s</code>. There are <code class="language-plaintext highlighter-rouge">WrapGenerate</code> and <code class="language-plaintext highlighter-rouge">WrapTool</code> hooks at the other two altitudes (the whole tool loop, and each tool execution), middleware can inject tools, and the built-ins (<code class="language-plaintext highlighter-rouge">Retry</code>, <code class="language-plaintext highlighter-rouge">Fallback</code>, tool approval, filesystem sandbox) compose the same way.</p>

<p><strong>ADK</strong> has callbacks, not middleware: <code class="language-plaintext highlighter-rouge">BeforeModelCallback</code> and <code class="language-plaintext highlighter-rouge">AfterModelCallback</code> with fixed signatures. They can short-circuit (return a response from <code class="language-plaintext highlighter-rouge">Before</code> and the model is never called, the documented caching hook) and they can replace the response, but they cannot <em>wrap</em>. There is no <code class="language-plaintext highlighter-rouge">next()</code>. To time a call I had to smuggle state between two separate functions:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">start</span> <span class="n">time</span><span class="o">.</span><span class="n">Time</span>

<span class="n">before</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">agent</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="o">*</span><span class="n">model</span><span class="o">.</span><span class="n">LLMRequest</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">model</span><span class="o">.</span><span class="n">LLMResponse</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">Now</span><span class="p">()</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span> <span class="c">// nil means "continue to the model"</span>
<span class="p">}</span>
<span class="n">after</span> <span class="o">:=</span> <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">agent</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">resp</span> <span class="o">*</span><span class="n">model</span><span class="o">.</span><span class="n">LLMResponse</span><span class="p">,</span> <span class="n">respErr</span> <span class="kt">error</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">model</span><span class="o">.</span><span class="n">LLMResponse</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"took %s"</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">Since</span><span class="p">(</span><span class="n">start</span><span class="p">))</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This works (ran, <code class="language-plaintext highlighter-rouge">took 1.631s</code>) but it’s a workaround, it’s attached per-agent rather than per-call, and anything genuinely wrap-shaped (hedged requests, per-attempt timeouts, fallbacks across models) has to move to a different mechanism entirely: decorating the model. Which brings me to the thing ADK gets <em>really</em> right:</p>

<h3 id="custom-model-backends-local-gemma-on-ollama">Custom model backends: local Gemma on Ollama</h3>

<p>Nobody plugs in an echo model, so no toy examples here. The realistic scenario is a backend the framework didn’t bless for you: a local model server, an internal gateway, a provider without an official plugin. I tested two levels of this: running Google’s Gemma locally on Ollama, and writing a backend from scratch.</p>

<p>Genkit ships an official Ollama plugin, so the local model is a first-class provider:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">oll</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">ollama</span><span class="o">.</span><span class="n">Ollama</span><span class="p">{</span><span class="n">ServerAddress</span><span class="o">:</span> <span class="s">"http://localhost:11434"</span><span class="p">}</span>
<span class="n">g</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Init</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">genkit</span><span class="o">.</span><span class="n">WithPlugins</span><span class="p">(</span><span class="n">oll</span><span class="p">))</span>

<span class="n">gemma</span> <span class="o">:=</span> <span class="n">oll</span><span class="o">.</span><span class="n">DefineModel</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">ollama</span><span class="o">.</span><span class="n">ModelDefinition</span><span class="p">{</span><span class="n">Name</span><span class="o">:</span> <span class="s">"gemma3n:e4b"</span><span class="p">,</span> <span class="n">Type</span><span class="o">:</span> <span class="s">"chat"</span><span class="p">},</span> <span class="no">nil</span><span class="p">)</span>

<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModel</span><span class="p">(</span><span class="n">gemma</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Why is Go a great language for building AI agents? One short sentence."</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p><strong>23 lines</strong>, ran on the first try against <code class="language-plaintext highlighter-rouge">gemma3n:e4b</code> on my Mac, and the local model gets the same <code class="language-plaintext highlighter-rouge">Generate</code>, flows, middleware and structured output as any cloud model. The plugin covers embedders too.</p>

<p>ADK has no Ollama integration of its own. The path that works is the OpenAI-compatible adapter pointed at Ollama’s OpenAI endpoint:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gemma</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">openaimodel</span><span class="o">.</span><span class="n">NewModel</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"gemma3n:e4b"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">openaimodel</span><span class="o">.</span><span class="n">ClientConfig</span><span class="p">{</span>
    <span class="n">BaseURL</span><span class="o">:</span> <span class="s">"http://localhost:11434/v1"</span><span class="p">,</span>
    <span class="n">APIKey</span><span class="o">:</span>  <span class="s">"ollama"</span><span class="p">,</span> <span class="c">// any non-empty value; Ollama ignores it</span>
<span class="p">})</span>
</code></pre></div></div>

<p>This also ran (<strong>47 lines</strong> with the usual agent, runner and session around it), and it is a documented path: the package README lists Ollama, LM Studio and vLLM. The difference is the shape of the support. In Genkit the local model is a provider with its own plugin; in ADK it rides on OpenAI compatibility, so anything Ollama offers outside that API surface is out of reach, and then you are writing the backend yourself, which is the next scenario.</p>

<h3 id="and-when-there-is-no-plugin-at-all">And when there is no plugin at all</h3>

<p>When the backend has no plugin and no OpenAI-compatible endpoint (an internal LLM gateway, a niche provider), you write the integration yourself. I did, in both frameworks: the same minimal OpenAI <code class="language-plaintext highlighter-rouge">chat/completions</code> client, roughly 45 lines of identical plain-<code class="language-plaintext highlighter-rouge">net/http</code> glue, run against the real OpenAI API.</p>

<p>ADK’s <code class="language-plaintext highlighter-rouge">model.LLM</code> interface is the smallest custom-backend contract I’ve seen in any framework:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">LLM</span> <span class="k">interface</span> <span class="p">{</span>
    <span class="n">Name</span><span class="p">()</span> <span class="kt">string</span>
    <span class="n">GenerateContent</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="o">*</span><span class="n">LLMRequest</span><span class="p">,</span> <span class="n">stream</span> <span class="kt">bool</span><span class="p">)</span> <span class="n">iter</span><span class="o">.</span><span class="n">Seq2</span><span class="p">[</span><span class="o">*</span><span class="n">LLMResponse</span><span class="p">,</span> <span class="kt">error</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two methods, and this interface is also ADK’s genuine wrapping seam: ADK’s own examples ship a <code class="language-plaintext highlighter-rouge">resilientModel</code> decorator doing retries and per-attempt timeouts this way. My OpenAI-backed <code class="language-plaintext highlighter-rouge">model.LLM</code> worked, with two costs on top of the shared HTTP glue. First, the vocabulary: requests and responses are <code class="language-plaintext highlighter-rouge">google.golang.org/genai</code> types, so you translate both ways, and some of it is non-obvious. The agent’s <code class="language-plaintext highlighter-rouge">Instruction</code> arrives in <code class="language-plaintext highlighter-rouge">Config.SystemInstruction</code>, not in <code class="language-plaintext highlighter-rouge">Contents</code>; miss that and your backend silently drops the system prompt. (ADK’s own OpenAI adapter is four files of translation code, which tells you how deep that rabbit hole goes. Also, parts of ADK sniff behavior from <code class="language-plaintext highlighter-rouge">llm.Name()</code>, so a renaming wrapper can silently change how output schemas are handled.) Second, the invocation: those two beautiful methods still need the agent, runner and session stack around them to run. Total: <strong>116 lines</strong>.</p>

<p>The Genkit version of the same backend is the HTTP glue, a capability declaration, and nothing else:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">callOpenAI</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">req</span> <span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelRequest</span><span class="p">,</span> <span class="n">cb</span> <span class="k">func</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelResponseChunk</span><span class="p">)</span> <span class="kt">error</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelResponse</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">body</span> <span class="o">:=</span> <span class="n">oaRequest</span><span class="p">{</span><span class="n">Model</span><span class="o">:</span> <span class="s">"gpt-5-mini"</span><span class="p">}</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">req</span><span class="o">.</span><span class="n">Messages</span> <span class="p">{</span>
        <span class="n">role</span> <span class="o">:=</span> <span class="kt">string</span><span class="p">(</span><span class="n">m</span><span class="o">.</span><span class="n">Role</span><span class="p">)</span> <span class="c">// "system" | "user" | "model"</span>
        <span class="k">if</span> <span class="n">role</span> <span class="o">==</span> <span class="s">"model"</span> <span class="p">{</span>
            <span class="n">role</span> <span class="o">=</span> <span class="s">"assistant"</span>
        <span class="p">}</span>
        <span class="n">body</span><span class="o">.</span><span class="n">Messages</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">body</span><span class="o">.</span><span class="n">Messages</span><span class="p">,</span> <span class="n">oaMessage</span><span class="p">{</span><span class="n">Role</span><span class="o">:</span> <span class="n">role</span><span class="p">,</span> <span class="n">Content</span><span class="o">:</span> <span class="n">m</span><span class="o">.</span><span class="n">Text</span><span class="p">()})</span>
    <span class="p">}</span>
    <span class="c">// ... plain net/http POST to api.openai.com/v1/chat/completions ...</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelResponse</span><span class="p">{</span>
        <span class="n">FinishReason</span><span class="o">:</span> <span class="n">ai</span><span class="o">.</span><span class="n">FinishReasonStop</span><span class="p">,</span>
        <span class="n">Message</span><span class="o">:</span>      <span class="n">ai</span><span class="o">.</span><span class="n">NewModelTextMessage</span><span class="p">(</span><span class="n">out</span><span class="o">.</span><span class="n">Choices</span><span class="p">[</span><span class="m">0</span><span class="p">]</span><span class="o">.</span><span class="n">Message</span><span class="o">.</span><span class="n">Content</span><span class="p">),</span>
    <span class="p">},</span> <span class="no">nil</span>
<span class="p">}</span>

<span class="n">gpt</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">DefineModel</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"myco/gpt-5-mini"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelOptions</span><span class="p">{</span>
    <span class="n">Supports</span><span class="o">:</span> <span class="o">&amp;</span><span class="n">ai</span><span class="o">.</span><span class="n">ModelSupports</span><span class="p">{</span><span class="n">Multiturn</span><span class="o">:</span> <span class="no">true</span><span class="p">,</span> <span class="n">SystemRole</span><span class="o">:</span> <span class="no">true</span><span class="p">},</span>
<span class="p">},</span> <span class="n">callOpenAI</span><span class="p">)</span>

<span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Generate</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithModel</span><span class="p">(</span><span class="n">gpt</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithSystem</span><span class="p">(</span><span class="s">"You are a helpful assistant."</span><span class="p">),</span>
    <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Why is Go a great language for building AI agents? One short sentence."</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Total: <strong>79 lines</strong>, and <code class="language-plaintext highlighter-rouge">Generate</code> calls it directly; the model immediately works with structured output, streaming, middleware and flows. The messages arrive as provider-neutral structs (<code class="language-plaintext highlighter-rouge">ai.Message</code>, with system prompt as a regular <code class="language-plaintext highlighter-rouge">system</code>-role message), so the translation is one role-mapping loop instead of a genai round-trip. One honest note: my first attempt failed at runtime because I passed <code class="language-plaintext highlighter-rouge">nil</code> options, since a Genkit model must declare what it <code class="language-plaintext highlighter-rouge">Supports</code>. That cost me one extra struct literal, and it’s the flip side of a real feature: the framework validates every request against the declared capabilities and even simulates a system prompt for models that don’t support one.</p>

<h3 id="the-raw-call-escape-hatch">The raw call escape hatch</h3>

<p>I didn’t want to write “you can’t go low-level in ADK” without trying it myself, so I tried. <code class="language-plaintext highlighter-rouge">model.LLM</code> is public, and this compiles and runs (33 lines in my experiment):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">llm</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">gemini</span><span class="o">.</span><span class="n">NewModel</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"gemini-3-flash-preview"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">genai</span><span class="o">.</span><span class="n">ClientConfig</span><span class="p">{</span><span class="n">APIKey</span><span class="o">:</span> <span class="n">key</span><span class="p">})</span>

<span class="k">for</span> <span class="n">resp</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">llm</span><span class="o">.</span><span class="n">GenerateContent</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">model</span><span class="o">.</span><span class="n">LLMRequest</span><span class="p">{</span>
    <span class="n">Contents</span><span class="o">:</span> <span class="p">[]</span><span class="o">*</span><span class="n">genai</span><span class="o">.</span><span class="n">Content</span><span class="p">{</span><span class="n">genai</span><span class="o">.</span><span class="n">NewContentFromText</span><span class="p">(</span><span class="n">prompt</span><span class="p">,</span> <span class="n">genai</span><span class="o">.</span><span class="n">RoleUser</span><span class="p">)},</span>
<span class="p">},</span> <span class="no">false</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// resp.Content.Parts ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>But notice what you’re holding: this is the bare genai transport with an ADK-shaped envelope. No instruction templating, no tool loop, no callbacks, no structured output processing, no telemetry: all of that lives inside the agent flow you just bypassed. There are no helpers (<code class="language-plaintext highlighter-rouge">Text()</code>, message builders) at this layer, and no example in the entire ADK repo uses it; only tests do. It’s an escape hatch, not a supported altitude.</p>

<p>That’s the crux of the low-level story: <strong>in Genkit, dropping down doesn’t cost you the framework.</strong> <code class="language-plaintext highlighter-rouge">Generate</code> <em>is</em> the low-level call: raw messages in, with middleware, tools, schemas and tracing still active. In ADK, the abstraction is a cliff: you’re either at agent altitude with everything, or at transport altitude with nothing.</p>

<h2 id="where-adks-ceremony-pays-off">Where ADK’s ceremony pays off</h2>

<p>I also ran the experiments where I expected ADK’s extra structure to earn its keep. It mostly did.</p>

<p><strong>Multi-turn conversation.</strong> Both frameworks carry the conversation for you; neither makes you thread history by hand.</p>

<p>In ADK, I called <code class="language-plaintext highlighter-rouge">r.Run</code> twice with the same session ID and the second turn knew my name and city. The session service <em>is</em> the conversation, and swapping <code class="language-plaintext highlighter-rouge">InMemoryService</code> for the database or Vertex AI implementation brings persistence with it.</p>

<p>In Genkit, the agents API does the same job with a session store. I ran the equivalent two turns on v1.11.0:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">g</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Init</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span>
    <span class="n">genkit</span><span class="o">.</span><span class="n">WithPlugins</span><span class="p">(</span><span class="o">&amp;</span><span class="n">googlegenai</span><span class="o">.</span><span class="n">GoogleAI</span><span class="p">{}),</span>
    <span class="n">genkit</span><span class="o">.</span><span class="n">WithExperimental</span><span class="p">(),</span>
<span class="p">)</span>

<span class="n">assistant</span> <span class="o">:=</span> <span class="n">genkitx</span><span class="o">.</span><span class="n">DefineAgent</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"assistant"</span><span class="p">,</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">InlinePrompt</span><span class="p">{</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithSystem</span><span class="p">(</span><span class="s">"You are a helpful assistant."</span><span class="p">),</span>
    <span class="p">},</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">WithSessionStore</span><span class="p">(</span><span class="n">localstore</span><span class="o">.</span><span class="n">NewInMemorySessionStore</span><span class="p">[</span><span class="n">any</span><span class="p">]()),</span>
<span class="p">)</span>

<span class="n">first</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">assistant</span><span class="o">.</span><span class="n">RunText</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"My name is Xavi and I live in Valencia. Say hi in one sentence."</span><span class="p">)</span>
<span class="c">// ...</span>
<span class="n">second</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">assistant</span><span class="o">.</span><span class="n">RunText</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"Where do I live? Answer with just the city name."</span><span class="p">,</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">WithSessionID</span><span class="p">[</span><span class="n">any</span><span class="p">](</span><span class="n">first</span><span class="o">.</span><span class="n">SessionID</span><span class="p">))</span>
</code></pre></div></div>

<p>Both frameworks answered <code class="language-plaintext highlighter-rouge">turn 2: Valencia</code> in my runs, with zero history plumbing on either side. And Genkit’s session layer goes further than a message log: session snapshots, detach/resume (the client disconnects, the server keeps working, you resume from a snapshot) and artifacts, with in-memory and file stores shipping today plus a <code class="language-plaintext highlighter-rouge">SessionStore</code> interface for your own persistence. For conversation state, the two frameworks are at parity. (If you are outside an agent, composing plain <code class="language-plaintext highlighter-rouge">Generate</code> calls, <code class="language-plaintext highlighter-rouge">ai.WithMessages(resp.History()...)</code> threads history in one line, but that is a choice, not a limitation.)</p>

<p>The one difference I would plan around is the maturity label. ADK’s session services are GA and are the default path; Genkit’s agents API is on the experimental track behind <code class="language-plaintext highlighter-rouge">genkit.WithExperimental()</code> and can change between minor releases, which is also why the rest of this article compares the stable surfaces. If you are comfortable riding the experimental track, Genkit gives you all of it today; if you need the stability guarantee, ADK has it.</p>

<p><strong>Multi-agent.</strong> ADK’s sub-agents get an auto-injected <code class="language-plaintext highlighter-rouge">transfer_to_agent</code> tool, there are sequential/parallel/loop workflow agents, and 2.0’s headline feature is a full graph workflow engine with typed routing between nodes, retries and human-in-the-loop pauses.</p>

<p>Genkit delegates too. The <code class="language-plaintext highlighter-rouge">Agents</code> middleware injects one <code class="language-plaintext highlighter-rouge">delegate_to_&lt;name&gt;</code> tool per sub-agent, discovers each sub-agent’s description automatically for the orchestrator’s prompt, forwards recent conversation history, and can merge sub-agent artifacts back into the orchestrator’s session. I ran an orchestrator delegating to a poet sub-agent:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">orchestrator</span> <span class="o">:=</span> <span class="n">genkitx</span><span class="o">.</span><span class="n">DefineAgent</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"orchestrator"</span><span class="p">,</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">InlinePrompt</span><span class="p">{</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="s">"googleai/gemini-3-flash-preview"</span><span class="p">),</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithSystem</span><span class="p">(</span><span class="s">"You are a coordinator. Delegate writing tasks to the right sub-agent using its delegation tool, then return its result verbatim."</span><span class="p">),</span>
        <span class="n">ai</span><span class="o">.</span><span class="n">WithUse</span><span class="p">(</span><span class="o">&amp;</span><span class="n">middlewarex</span><span class="o">.</span><span class="n">Agents</span><span class="p">{</span>
            <span class="n">Agents</span><span class="o">:</span>         <span class="p">[]</span><span class="n">aix</span><span class="o">.</span><span class="n">AgentRef</span><span class="p">{</span><span class="n">poet</span><span class="o">.</span><span class="n">Ref</span><span class="p">()},</span>
            <span class="n">MaxDelegations</span><span class="o">:</span> <span class="m">2</span><span class="p">,</span>
        <span class="p">}),</span>
    <span class="p">},</span>
    <span class="n">aix</span><span class="o">.</span><span class="n">WithSessionStore</span><span class="p">(</span><span class="n">localstore</span><span class="o">.</span><span class="n">NewInMemorySessionStore</span><span class="p">[</span><span class="n">any</span><span class="p">]()),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>43 lines, worked on the first run (I got my haiku back through the orchestrator), and the full researcher-plus-engineer version is in the official <a href="https://github.com/genkit-ai/genkit/tree/main/go/samples/basic-agents">basic-agents sample</a>. Same maturity note as sessions: this is the experimental track.</p>

<p>And ADK’s workflow layer, the sequential/parallel/loop agents and the 2.0 graph engine? Genkit’s answer is that the orchestration language is Go itself. <a href="https://genkit.dev/docs/go/flows/">Flows compose</a>: a flow calls other flows, sequence is code order, parallel is a goroutine group, a loop is a loop, and every flow stays traced and individually servable. I ran a brief-writing flow that fans out to two sub-flows in parallel and then synthesizes:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">brief</span> <span class="o">:=</span> <span class="n">genkit</span><span class="o">.</span><span class="n">DefineFlow</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="s">"briefFlow"</span><span class="p">,</span>
    <span class="k">func</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="c">// Parallel: plain Go concurrency.</span>
        <span class="k">var</span> <span class="n">facts</span><span class="p">,</span> <span class="n">structure</span> <span class="kt">string</span>
        <span class="n">eg</span><span class="p">,</span> <span class="n">egCtx</span> <span class="o">:=</span> <span class="n">errgroup</span><span class="o">.</span><span class="n">WithContext</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>
        <span class="n">eg</span><span class="o">.</span><span class="n">Go</span><span class="p">(</span><span class="k">func</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
            <span class="k">var</span> <span class="n">err</span> <span class="kt">error</span>
            <span class="n">facts</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">research</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">egCtx</span><span class="p">,</span> <span class="n">topic</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">err</span>
        <span class="p">})</span>
        <span class="n">eg</span><span class="o">.</span><span class="n">Go</span><span class="p">(</span><span class="k">func</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
            <span class="k">var</span> <span class="n">err</span> <span class="kt">error</span>
            <span class="n">structure</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">outline</span><span class="o">.</span><span class="n">Run</span><span class="p">(</span><span class="n">egCtx</span><span class="p">,</span> <span class="n">topic</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">err</span>
        <span class="p">})</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">eg</span><span class="o">.</span><span class="n">Wait</span><span class="p">();</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
            <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">err</span>
        <span class="p">}</span>

        <span class="c">// Sequential: the next line of code.</span>
        <span class="k">return</span> <span class="n">genkit</span><span class="o">.</span><span class="n">GenerateText</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">g</span><span class="p">,</span>
            <span class="n">ai</span><span class="o">.</span><span class="n">WithModelName</span><span class="p">(</span><span class="n">model</span><span class="p">),</span>
            <span class="n">ai</span><span class="o">.</span><span class="n">WithPrompt</span><span class="p">(</span><span class="s">"Write a two-sentence brief about %s using these facts:</span><span class="se">\n</span><span class="s">%s</span><span class="se">\n</span><span class="s">and this outline:</span><span class="se">\n</span><span class="s">%s"</span><span class="p">,</span> <span class="n">topic</span><span class="p">,</span> <span class="n">facts</span><span class="p">,</span> <span class="n">structure</span><span class="p">))</span>
    <span class="p">})</span>
</code></pre></div></div>

<p><strong>53 lines</strong> including both sub-flows, ran on stable APIs, and <code class="language-plaintext highlighter-rouge">genkit.Handler(brief)</code> serves it over HTTP like any other flow. So the difference between the two workflow stories is representation, not capability. ADK reifies the pipeline as a data structure (edges, typed routes) executed by an engine that owns retries, pauses and resumability at the graph level; Genkit keeps the pipeline as ordinary Go and covers those concerns with its own pieces (tool interrupts for human-in-the-loop, the retry middleware, and durable execution through integrations like Temporal). Pick by taste: an inspectable graph object, or code you read top to bottom.</p>

<p><strong>The serving platform.</strong> Genkit’s HTTP story is its philosophy in miniature. A flow becomes a handler on plain <code class="language-plaintext highlighter-rouge">net/http</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mux</span> <span class="o">:=</span> <span class="n">http</span><span class="o">.</span><span class="n">NewServeMux</span><span class="p">()</span>
<span class="n">mux</span><span class="o">.</span><span class="n">HandleFunc</span><span class="p">(</span><span class="s">"POST /greetingFlow"</span><span class="p">,</span> <span class="n">genkit</span><span class="o">.</span><span class="n">Handler</span><span class="p">(</span><span class="n">greetingFlow</span><span class="p">))</span>
<span class="n">server</span><span class="o">.</span><span class="n">Start</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"127.0.0.1:9090"</span><span class="p">,</span> <span class="n">mux</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl <span class="nt">-X</span> POST localhost:9090/greetingFlow <span class="nt">-d</span> <span class="s1">'{"data":"Xavi"}'</span>
<span class="o">{</span><span class="s2">"result"</span>:<span class="s2">"Hello, Xavi!"</span><span class="o">}</span>
</code></pre></div></div>

<p>ADK’s <code class="language-plaintext highlighter-rouge">adkrest.NewServer</code> is also a plain <code class="language-plaintext highlighter-rouge">http.Handler</code> (nice), but what it mounts is a <em>platform</em>: app listing, session CRUD, SSE runtime, artifacts, debug/trace endpoints. My curl transcript needed two calls (create the session, then run) and got back the full event envelope with usage metadata, state deltas and node info. On top of that, ADK’s launcher embeds the entire ADK Web UI into your binary via <code class="language-plaintext highlighter-rouge">go:embed</code>: <code class="language-plaintext highlighter-rouge">go run . web webui</code> gives you a chat UI with no npm and no separate process, which is honestly great. (Genkit’s Developer UI is a separate CLI process, though it’s also more than a chat UI: it traces every generate call, middleware hop and tool execution.)</p>

<p>These aren’t contradictions of the boilerplate findings; they’re the same design measured on its own terrain. ADK’s five constructors are the price of a system where conversations persist, agents transfer to each other, and a REST platform falls out of a config struct.</p>

<h2 id="the-plugin-ecosystems">The plugin ecosystems</h2>

<p>One more thing I checked in the released modules, because it changes the decision for real projects: what each framework integrates with out of the box.</p>

<p>Genkit Go v1.11.0 ships sixteen plugins in the module: model providers for Google (Gemini API and Vertex AI), <strong>Anthropic</strong>, <strong>OpenAI plus any OpenAI-compatible endpoint</strong>, and <strong>Ollama</strong>; vector stores for <strong>AlloyDB, PostgreSQL/pgvector, Pinecone, Weaviate</strong> and a local dev store; <strong>Firebase</strong> and <strong>Google Cloud</strong> telemetry; <strong>MCP</strong>; evaluators; and the middleware pack. And that is the Go module alone, before you count the wider JS ecosystem the framework shares its design with.</p>

<p>ADK Go v2.1.0’s integrations are a coherent Google Cloud story: Gemini and Vertex models, the OpenAI-compatible adapter, <strong>Apigee</strong>; sessions in memory, in a SQL database or on <strong>Vertex AI</strong>; <strong>GCS</strong> artifacts; <strong>Vertex AI</strong> memory; <strong>BigQuery</strong> agent analytics; MCP toolsets; and deploy tooling for <strong>Cloud Run</strong> and <strong>Agent Engine</strong>. What it does not have is a provider ecosystem beyond that: no Anthropic plugin, no vector store integrations, nothing aimed at other clouds.</p>

<p>If your stack is pure Google Cloud, ADK’s set covers it end to end, deploy included. The moment you need Anthropic next to Gemini, a Postgres vector store, or observability outside Google Cloud, Genkit has the plugin and ADK expects you to build it.</p>

<h2 id="the-scorecard">The scorecard</h2>

<p>Numbers from my runnable programs (non-blank lines including imports; error-handling lines in parentheses):</p>

<table>
  <thead>
    <tr>
      <th>Experiment</th>
      <th>Genkit Go</th>
      <th>ADK Go 2.0</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Hello world</td>
      <td>21 (2)</td>
      <td>49 (8)</td>
    </tr>
    <tr>
      <td>Raw model call</td>
      <td>same as hello</td>
      <td>33 (4), undocumented path</td>
    </tr>
    <tr>
      <td>Structured output → typed struct</td>
      <td>28 (2)</td>
      <td>68 (9)</td>
    </tr>
    <tr>
      <td>Streaming</td>
      <td>27 (2)</td>
      <td>54 (8)</td>
    </tr>
    <tr>
      <td>One tool</td>
      <td>29 (2)</td>
      <td>66 (10)</td>
    </tr>
    <tr>
      <td>Time the model call</td>
      <td>34 (2)</td>
      <td>61 (8)</td>
    </tr>
    <tr>
      <td>Local Gemma via Ollama, invoked</td>
      <td>23 (2)</td>
      <td>47 (8)</td>
    </tr>
    <tr>
      <td>Custom model backend (real OpenAI), invoked</td>
      <td>79 (5)</td>
      <td>116 (13)</td>
    </tr>
    <tr>
      <td>Multi-turn (2 turns)</td>
      <td>37 (4) with agents + sessions; 30 (4) with plain <code class="language-plaintext highlighter-rouge">Generate</code></td>
      <td>51 (8)</td>
    </tr>
  </tbody>
</table>

<p>Line counts are a blunt instrument; the interesting part is <em>what the extra lines are</em>. In every ADK program they are the same lines: construct model, construct agent, construct runner, invent user/session IDs, iterate events, filter partials, concatenate parts. It’s a fixed tax, roughly 25-30 lines, charged on every program regardless of whether sessions and events are relevant to it.</p>

<h2 id="a-concrete-decision-guide">A concrete decision guide</h2>

<p>I would reach for <strong>Genkit Go</strong> when:</p>

<ul>
  <li>The unit of my product is a <strong>model call or a flow</strong>: an API endpoint, a pipeline step, a CLI, a backend feature.</li>
  <li>I want <strong>typed structured output</strong> without maintaining schema trees by hand.</li>
  <li>I want to <strong>wrap, retry, fall back, cache or rewrite</strong> model calls with composable per-call middleware.</li>
  <li>I’m integrating a <strong>custom or non-Google model backend</strong> and want the framework features to keep working on top of it.</li>
  <li>I value the Dev UI’s per-call tracing during development.</li>
  <li>I want sessions and agents too, and I’m fine with the experimental track while that API stabilizes.</li>
  <li>My stack mixes vendors: Anthropic or OpenAI next to Gemini, Ollama locally, or Postgres/Pinecone/Weaviate for retrieval. The plugin shelf already covers them.</li>
</ul>

<p>I would reach for <strong>ADK Go 2.0</strong> when:</p>

<ul>
  <li>The unit of my product is a <strong>persistent, session-based agent</strong>: a chat product, a support agent, anything where conversation state is the point, and I want it on GA APIs today.</li>
  <li>I need <strong>multi-agent coordination</strong>: sub-agent transfer, workflow agents, or 2.0’s graph engine with typed routing and HITL pauses.</li>
  <li>I want the <strong>serving platform for free</strong>: session REST API, SSE, the embedded Web UI, A2A, Cloud Run / Agent Engine deploy tooling.</li>
  <li>My stack is Gemini/Vertex-first and the <code class="language-plaintext highlighter-rouge">genai</code>-typed vocabulary is a feature, not a translation burden.</li>
</ul>

<p>And these compose better than you’d think: nothing stops you from building your model-centric services with Genkit flows and standing up an ADK agent where you genuinely need the agent platform. They’re both idiomatic modern Go (both lean on <code class="language-plaintext highlighter-rouge">iter.Seq2</code> and generics nicely), and they’re solving different layers of the same problem.</p>

<h2 id="conclusion">Conclusion</h2>

<p>After running everything, the facts are these: Genkit lets you get lower-level, and ADK charges more boilerplate. In detail:</p>

<ul>
  <li>The boilerplate is real and remarkably consistent: ADK charges a fixed ~25-30 line agent tax on every program, because <strong>there is no supported altitude between “full agent” and “bare transport”</strong>. Genkit’s <code class="language-plaintext highlighter-rouge">Generate</code> occupies exactly that missing middle: raw request access with the framework still on.</li>
  <li>The low-level gap is sharpest in <strong>structured output</strong> (typed structs vs hand-built schema trees plus DIY parsing, and a runner that deliberately strips the parsed value it already has) and in <strong>middleware</strong> (a real <code class="language-plaintext highlighter-rouge">next()</code> vs fixed-signature callbacks).</li>
  <li>But ADK’s ceremony is not waste: it buys GA session persistence, multi-agent transfer, a graph engine and an embedded serving platform, and some of its individual designs (the two-method <code class="language-plaintext highlighter-rouge">model.LLM</code>, generic function tools) are excellent Go.</li>
</ul>

<p>Pick the altitude that matches your product, and don’t confuse “more setup” with “more capable”, or “less setup” with “less serious.” They’re different tools that happen to share a language and a model provider.</p>

<p>Further reading:</p>

<ul>
  <li><a href="https://genkit.dev/docs/go/get-started/">Genkit Go Get Started guide</a></li>
  <li><a href="https://adk.dev/2.0/">ADK 2.0 release notes</a></li>
  <li><a href="https://github.com/genkit-ai/genkit">Genkit GitHub repository</a></li>
  <li><a href="https://github.com/google/adk-go">ADK Go GitHub repository</a></li>
  <li><a href="https://github.com/genkit-ai/genkit/tree/main/go/samples/basic-agents">Genkit Go basic-agents sample (orchestrator, delegation, artifacts)</a></li>
  <li><a href="https://genkit.dev/docs/go/flows/">Genkit Go flows documentation</a></li>
  <li><a href="/genkit/2026-05-04-stop-using-python-genai-use-genkit-go/">Stop Using Python for Gen AI: Genkit Go</a></li>
  <li><a href="/genkit/2026-05-13-vercel-ai-sdk-vs-genkit-middleware/">Vercel AI SDK Middleware vs Genkit Middleware</a></li>
</ul>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="genkit" /><category term="genkit" /><category term="adk" /><category term="golang" /><category term="gemini" /><summary type="html"><![CDATA[I built the same eight programs twice, once with Genkit Go and once with Google's ADK Go 2.0, and ran them all against Gemini: hello world, structured output, streaming, tools, middleware, custom models, multi-turn and HTTP serving. This is what the code actually looks like in each, where ADK makes you pay an "agent tax", where Genkit lets you drop lower, and where ADK's ceremony genuinely pays off.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/genkit-go-vs-adk-go.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/genkit-go-vs-adk-go.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Rotate AWS Secrets Manager Secrets Without Restarting Your Go Service, with mamori (English)</title><link href="https://xavidop.me/aws/2026-07-22-mamori-rotate-aws-secrets-manager-go/" rel="alternate" type="text/html" title="Rotate AWS Secrets Manager Secrets Without Restarting Your Go Service, with mamori (English)" /><published>2026-07-22T00:00:00+00:00</published><updated>2026-07-22T16:48:27+00:00</updated><id>https://xavidop.me/aws/mamori-rotate-aws-secrets-manager-go</id><content type="html" xml:base="https://xavidop.me/aws/2026-07-22-mamori-rotate-aws-secrets-manager-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-two-aws-schemes" id="markdown-toc-the-two-aws-schemes">The two AWS schemes</a></li>
  <li><a href="#setup" id="markdown-toc-setup">Setup</a></li>
  <li><a href="#load-then-watch-the-rotation" id="markdown-toc-load-then-watch-the-rotation">Load, then watch the rotation</a></li>
  <li><a href="#polling-done-right-jitter-batching-atomicity" id="markdown-toc-polling-done-right-jitter-batching-atomicity">Polling done right (jitter, batching, atomicity)</a></li>
  <li><a href="#tolerate-a-blip-page-on-a-real-outage" id="markdown-toc-tolerate-a-blip-page-on-a-real-outage">Tolerate a blip, page on a real outage</a></li>
  <li><a href="#mixing-sources-in-one-struct" id="markdown-toc-mixing-sources-in-one-struct">Mixing sources in one struct</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>AWS Secrets Manager has a lovely feature: <strong>automatic rotation</strong>. You attach a rotation Lambda, set a schedule, and every 30 days your RDS password is rotated for you. Secure by default, no human involved.</p>

<p>There’s just one problem, and it’s the one nobody mentions in the demo: <strong>your application has no idea it happened.</strong></p>

<p>Your Go service read <code class="language-plaintext highlighter-rouge">SecretString</code> once, at startup, and stashed the password in a struct. Thirty days later Secrets Manager quietly rotates it, the old value stops working, your connection pool starts throwing auth errors, and you find out via a pager alert at 3am. The “fix” everyone reaches for is to redeploy on a schedule, or restart pods so the app re-reads the secret — which turns a security feature into an operational chore.</p>

<p><a href="https://mamorigo.dev"><strong>mamori</strong></a> is a Go library I built to close exactly this gap. It watches AWS Secrets Manager (and SSM Parameter Store) and reconciles a rotation into your <strong>running</strong> process — typed, validated, redacted — so a rotation triggers a connection-pool rebuild instead of a restart.</p>

<blockquote>
  <p>This is the AWS-focused companion to the launch article, <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori: Typed, Watchable Config &amp; Secrets for Go</a>. Read that first for the full model; here we go deep on AWS.</p>
</blockquote>

<h2 id="the-two-aws-schemes">The two AWS schemes</h2>

<p>The <code class="language-plaintext highlighter-rouge">providers/aws</code> module registers two ref schemes:</p>

<table>
  <thead>
    <tr>
      <th>Ref</th>
      <th>Reads from</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aws-sm://&lt;name&gt;[#json-key]</code></td>
      <td>Secrets Manager</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aws-ps://&lt;name&gt;[#json-key]</code></td>
      <td>SSM Parameter Store</td>
    </tr>
  </tbody>
</table>

<p>A few real refs — note the <strong>triple slash</strong> on Parameter Store, because SSM parameter names are absolute paths that start with <code class="language-plaintext highlighter-rouge">/</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws-sm://prod/api-key            # whole secret value
aws-sm://prod/db#password        # one key out of a JSON secret
aws-ps:///myapp/log-level        # SSM parameter (triple slash!)
aws-ps:///myapp/db#password      # one key out of a JSON SSM parameter
</code></pre></div></div>

<p>The rule of thumb: <strong>secrets</strong> (passwords, tokens, keys) belong in Secrets Manager (<code class="language-plaintext highlighter-rouge">aws-sm</code>), and plain, non-sensitive <strong>config</strong> (log levels, worker counts, feature toggles) belongs in the cheaper Parameter Store (<code class="language-plaintext highlighter-rouge">aws-ps</code>). mamori lets you mix both in the same struct.</p>

<h2 id="setup">Setup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
go get github.com/xavidop/mamori/providers/aws
</code></pre></div></div>

<p>Register it with a blank import for zero-config (it uses the standard AWS credential chain — environment, shared config, IRSA on EKS, EC2 instance role):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/aws"</span>
</code></pre></div></div>

<p>Or, when you need to pin a region or pass a custom client, construct the provider explicitly and hand it to <code class="language-plaintext highlighter-rouge">Load</code>/<code class="language-plaintext highlighter-rouge">Watch</code> with <code class="language-plaintext highlighter-rouge">WithProvider</code> — this takes precedence over the registry for that call:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">awsprov</span> <span class="s">"github.com/xavidop/mamori/providers/aws"</span>

<span class="n">cfg</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Load</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithProvider</span><span class="p">(</span><span class="n">awsprov</span><span class="o">.</span><span class="n">NewSecretsManager</span><span class="p">(</span><span class="n">awsprov</span><span class="o">.</span><span class="n">WithRegion</span><span class="p">(</span><span class="s">"eu-west-1"</span><span class="p">))),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Your runtime’s IAM role needs the obvious permissions, scoped tightly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2012-10-17"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Statement"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"Effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Allow"</span><span class="p">,</span><span class="w"> </span><span class="nl">"Action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"secretsmanager:GetSecretValue"</span><span class="p">,</span><span class="w"> </span><span class="nl">"Resource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/*"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"Effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Allow"</span><span class="p">,</span><span class="w"> </span><span class="nl">"Action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ssm:GetParameter"</span><span class="p">,</span><span class="w">              </span><span class="nl">"Resource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"arn:aws:ssm:eu-west-1:123456789012:parameter/myapp/*"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="load-then-watch-the-rotation">Load, then watch the rotation</h2>

<p>Here’s a service that pulls its database password from Secrets Manager and its operational knobs from Parameter Store:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"log"</span>
	<span class="s">"time"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>

	<span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/aws"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"aws-sm://prod/db#password"`</span>
	<span class="n">APIKey</span>     <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"aws-sm://prod/api-key"`</span>
	<span class="n">LogLevel</span>   <span class="kt">string</span>        <span class="s">`source:"aws-ps:///myapp/log-level" default:"info" validate:"oneof=debug info warn error"`</span>
	<span class="n">Workers</span>    <span class="kt">int</span>           <span class="s">`source:"aws-ps:///myapp/workers" default:"4" validate:"gte=1,lte=256"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">ctx</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">()</span>

	<span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">WithPollInterval</span><span class="p">(</span><span class="m">60</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
			<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"DBPassword"</span><span class="p">)</span> <span class="p">{</span>
				<span class="c">// A rotation just landed — rebuild the pool, no restart.</span>
				<span class="n">pool</span><span class="o">.</span><span class="n">Rotate</span><span class="p">(</span><span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">DBPassword</span><span class="o">.</span><span class="n">Reveal</span><span class="p">())</span>
				<span class="n">log</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="s">"database password rotated and applied live"</span><span class="p">)</span>
			<span class="p">}</span>
		<span class="p">}),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_error"</span><span class="p">)</span> <span class="p">}),</span>
	<span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span>
	<span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"started with %d workers at %s"</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Workers</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">LogLevel</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DBPassword</code> and <code class="language-plaintext highlighter-rouge">APIKey</code> are <code class="language-plaintext highlighter-rouge">secret.String</code>, so they redact themselves everywhere — <code class="language-plaintext highlighter-rouge">log.Printf("%+v", cfg)</code> prints <code class="language-plaintext highlighter-rouge">[REDACTED]</code>, <code class="language-plaintext highlighter-rouge">json.Marshal</code> emits <code class="language-plaintext highlighter-rouge">"[REDACTED]"</code>, and <code class="language-plaintext highlighter-rouge">slog</code> logs them as <code class="language-plaintext highlighter-rouge">[REDACTED]</code>. The plaintext only ever escapes through an explicit <code class="language-plaintext highlighter-rouge">.Reveal()</code>. mamori even ships a <code class="language-plaintext highlighter-rouge">go vet</code> analyzer that fails the build if you put an <code class="language-plaintext highlighter-rouge">aws-sm://</code> value into a plain <code class="language-plaintext highlighter-rouge">string</code> instead of a <code class="language-plaintext highlighter-rouge">secret.String</code>.</p>

<h2 id="polling-done-right-jitter-batching-atomicity">Polling done right (jitter, batching, atomicity)</h2>

<p>Secrets Manager has no push notification for value changes, so mamori <strong>polls</strong> — but it polls the way you’d want a fleet to poll:</p>

<ul>
  <li><strong>Jittered intervals.</strong> The poll interval is spread by ±20% by default, so 200 pods don’t all hit <code class="language-plaintext highlighter-rouge">GetSecretValue</code> on the same tick and blow through your API rate limit. You can tune the spread with <code class="language-plaintext highlighter-rouge">WithJitter</code>.</li>
  <li><strong>Change detection is cheap.</strong> mamori compares the secret’s version (Secrets Manager’s <code class="language-plaintext highlighter-rouge">VersionId</code>) before doing anything, so an unchanged secret costs one lightweight check, not a full re-decode.</li>
  <li><strong>Batching.</strong> The AWS provider resolves multiple refs from the same account in as few API calls as possible, automatically — so a struct with ten <code class="language-plaintext highlighter-rouge">aws-sm://</code> fields doesn’t mean ten times the API traffic.</li>
  <li><strong>Atomic + validated.</strong> When a rotation is detected, mamori re-validates the <em>entire</em> struct and only swaps it in if it’s valid. <code class="language-plaintext highlighter-rouge">OnChange</code> never fires with a half-updated or invalid config.</li>
</ul>

<p>Compare this to the <a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_cache.html">AWS Secrets Manager caching client</a>: it caches and refreshes one secret at a time, with no struct composition, no validation, no diff-aware callback, and no config sources beyond Secrets Manager. mamori gives you the whole config surface — secrets <em>and</em> Parameter Store <em>and</em> env <em>and</em> files — in one typed struct with one reconciliation loop.</p>

<h2 id="tolerate-a-blip-page-on-a-real-outage">Tolerate a blip, page on a real outage</h2>

<p>Secrets Manager occasionally throttles or has a transient error. You don’t want that to page anyone — a blip should just retry with backoff and keep serving the last good value. But if Secrets Manager is <em>genuinely</em> unreachable for ten minutes, that’s worth waking someone up. <code class="language-plaintext highlighter-rouge">WithStale</code> draws that line:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithStale</span><span class="p">(</span><span class="m">10</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Minute</span><span class="p">),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">var</span> <span class="n">stale</span> <span class="o">*</span><span class="n">mamori</span><span class="o">.</span><span class="n">StaleError</span>
		<span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">As</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">stale</span><span class="p">)</span> <span class="p">{</span>
			<span class="n">pager</span><span class="o">.</span><span class="n">Page</span><span class="p">(</span><span class="s">"AWS config stale &gt;10m"</span><span class="p">,</span> <span class="n">stale</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"aws_transient_error"</span><span class="p">)</span> <span class="c">// blips: just a metric</span>
	<span class="p">}),</span>
<span class="p">)</span>
</code></pre></div></div>

<h2 id="mixing-sources-in-one-struct">Mixing sources in one struct</h2>

<p>The real payoff shows up when your config isn’t all in one place. mamori doesn’t care that these live in four different systems — it’s one struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"aws-sm://prod/db#password"`</span>     <span class="c">// Secrets Manager</span>
	<span class="n">Workers</span>    <span class="kt">int</span>           <span class="s">`source:"aws-ps:///myapp/workers"`</span>       <span class="c">// Parameter Store</span>
	<span class="n">LogLevel</span>   <span class="kt">string</span>        <span class="s">`source:"env:LOG_LEVEL" default:"info"`</span>  <span class="c">// environment</span>
	<span class="n">TLSCert</span>    <span class="p">[]</span><span class="kt">byte</span>        <span class="s">`source:"file:///etc/tls/tls.crt"`</span>       <span class="c">// local file</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Automatic rotation is only half a feature if your app has to restart to notice. mamori supplies the other half: it watches AWS Secrets Manager and Parameter Store and reconciles a rotation into your running Go service — typed, validated, redacted, no bounce.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/providers/aws
</code></pre></div></div>

<ul>
  <li><strong>Full introduction:</strong> <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori</a></li>
  <li><strong>Docs:</strong> <a href="https://mamorigo.dev/docs">mamorigo.dev/docs</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/xavidop/mamori">github.com/xavidop/mamori</a></li>
</ul>

<p>mamori is MIT-licensed and at <code class="language-plaintext highlighter-rouge">v0.1.0</code> — early days, so feedback and issues are genuinely welcome. Happy coding!</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="aws" /><category term="aws" /><category term="go" /><category term="golang" /><category term="mamori" /><category term="secrets" /><category term="secrets-manager" /><category term="secret-rotation" /><summary type="html"><![CDATA[AWS Secrets Manager will happily rotate your database password every 30 days. The problem is your Go service, which read that password once at boot and has no idea it changed. mamori closes that gap: it watches Secrets Manager and Parameter Store and reconciles rotations into your running process, typed, validated, and redacted.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/mamori-aws.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/mamori-aws.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Azure Key Vault Secrets That Reconcile Themselves in Go, with mamori (English)</title><link href="https://xavidop.me/azure/2026-07-22-mamori-azure-key-vault-go/" rel="alternate" type="text/html" title="Azure Key Vault Secrets That Reconcile Themselves in Go, with mamori (English)" /><published>2026-07-22T00:00:00+00:00</published><updated>2026-07-22T16:48:27+00:00</updated><id>https://xavidop.me/azure/mamori-azure-key-vault-go</id><content type="html" xml:base="https://xavidop.me/azure/2026-07-22-mamori-azure-key-vault-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-azure-kv-scheme" id="markdown-toc-the-azure-kv-scheme">The <code class="language-plaintext highlighter-rouge">azure-kv</code> scheme</a></li>
  <li><a href="#setup" id="markdown-toc-setup">Setup</a></li>
  <li><a href="#load-then-watch-the-rotation" id="markdown-toc-load-then-watch-the-rotation">Load, then watch the rotation</a></li>
  <li><a href="#what-reconciliation-guarantees" id="markdown-toc-what-reconciliation-guarantees">What reconciliation guarantees</a></li>
  <li><a href="#structured-secrets-with-flatten" id="markdown-toc-structured-secrets-with-flatten">Structured secrets with <code class="language-plaintext highlighter-rouge">flatten</code></a></li>
  <li><a href="#when-you-dont-need-this" id="markdown-toc-when-you-dont-need-this">When you don’t need this</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>Azure Key Vault is the well-behaved citizen of the Azure secrets world. Every secret is versioned, you get soft-delete and purge protection, access is governed by RBAC or access policies, and <code class="language-plaintext highlighter-rouge">DefaultAzureCredential</code> means your app authenticates the same way locally (<code class="language-plaintext highlighter-rouge">az login</code>) and in production (Managed Identity / Workload Identity on AKS) with zero code changes.</p>

<p>And yet the same old gap is there. Your Go service calls <code class="language-plaintext highlighter-rouge">GetSecret</code> once at startup, decodes the value into a struct, and treats it as immutable for the life of the process. When someone rotates the secret in Key Vault — a new version, the old one disabled — your service has no idea. It keeps using the value it read at boot until the next deploy or pod restart. The rotation was seamless in Azure and invisible to your app, which is exactly the wrong combination.</p>

<p><a href="https://mamorigo.dev"><strong>mamori</strong></a> is a Go library I built to fix this class of problem across every backend. Point it at a Key Vault secret and it watches for new versions, pulls the change, re-validates your whole config, and swaps it in atomically — <strong>while your service keeps running.</strong></p>

<blockquote>
  <p>This is the Azure entry in the mamori launch series. For the full model, start with <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori: Typed, Watchable Config &amp; Secrets for Go</a>; here we focus on Key Vault.</p>
</blockquote>

<h2 id="the-azure-kv-scheme">The <code class="language-plaintext highlighter-rouge">azure-kv</code> scheme</h2>

<p>The <code class="language-plaintext highlighter-rouge">providers/azure</code> module registers the <code class="language-plaintext highlighter-rouge">azure-kv</code> scheme. The grammar is <code class="language-plaintext highlighter-rouge">azure-kv://&lt;vault-name&gt;/&lt;secret-name&gt;[#json-key][?version=&lt;v&gt;]</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>azure-kv://my-vault/db-password              # latest enabled version
azure-kv://my-vault/api-key?version=abc123   # pin to a specific version id
azure-kv://my-vault/creds#password           # one key out of a JSON secret
</code></pre></div></div>

<p>One Key Vault quirk to keep in mind: versions are identified by <strong>opaque hex ids</strong>, not the sequential <code class="language-plaintext highlighter-rouge">1</code>, <code class="language-plaintext highlighter-rouge">2</code>, <code class="language-plaintext highlighter-rouge">3</code> you get in GCP Secret Manager. So <code class="language-plaintext highlighter-rouge">?version=</code> here pins to an id like <code class="language-plaintext highlighter-rouge">abc123def456...</code>, not a number. Leave <code class="language-plaintext highlighter-rouge">?version</code> off and mamori tracks whatever the latest enabled version resolves to — which is what you want for credentials that rotate.</p>

<h2 id="setup">Setup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
go get github.com/xavidop/mamori/providers/azure
</code></pre></div></div>

<p>Register it with a blank import. It authenticates through <code class="language-plaintext highlighter-rouge">DefaultAzureCredential</code>, so it picks up your <code class="language-plaintext highlighter-rouge">az login</code> locally, a Managed Identity on a VM/App Service, or Workload Identity on AKS — automatically, in that order:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/azure"</span>
</code></pre></div></div>

<p>The identity you bind needs read access to the vault’s secrets. With Key Vault’s RBAC model that’s the built-in <strong>Key Vault Secrets User</strong> role, scoped to the vault (or to individual secrets):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>az role assignment create <span class="se">\</span>
  <span class="nt">--role</span> <span class="s2">"Key Vault Secrets User"</span> <span class="se">\</span>
  <span class="nt">--assignee</span> <span class="s2">"&lt;managed-identity-object-id&gt;"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/subscriptions/&lt;sub&gt;/resourceGroups/&lt;rg&gt;/providers/Microsoft.KeyVault/vaults/my-vault"</span>
</code></pre></div></div>

<p>If your vault still uses the classic access-policy model instead of RBAC, grant <code class="language-plaintext highlighter-rouge">Get</code> (and <code class="language-plaintext highlighter-rouge">List</code>) on secrets.</p>

<h2 id="load-then-watch-the-rotation">Load, then watch the rotation</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"log"</span>
	<span class="s">"time"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>

	<span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/azure"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="c">// Tracks the latest enabled version: rotations flow in automatically.</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"azure-kv://my-vault/db-password"`</span>

	<span class="c">// Pinned to an immutable version id: deliberate, reproducible.</span>
	<span class="n">SigningKey</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"azure-kv://my-vault/signing-key?version=abc123"`</span>

	<span class="c">// One field out of a JSON secret.</span>
	<span class="n">APIToken</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"azure-kv://my-vault/creds#api_token"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">ctx</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">()</span>

	<span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">WithPollInterval</span><span class="p">(</span><span class="m">60</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
			<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"DBPassword"</span><span class="p">)</span> <span class="p">{</span>
				<span class="n">pool</span><span class="o">.</span><span class="n">Rotate</span><span class="p">(</span><span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">DBPassword</span><span class="o">.</span><span class="n">Reveal</span><span class="p">())</span> <span class="c">// live, no restart</span>
				<span class="n">log</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="s">"Key Vault: db-password rotated and applied"</span><span class="p">)</span>
			<span class="p">}</span>
		<span class="p">}),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_error"</span><span class="p">)</span> <span class="p">}),</span>
	<span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span> <span class="c">// last VALID config, lock-free</span>
	<span class="n">_</span> <span class="o">=</span> <span class="n">cfg</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every secret field is a <code class="language-plaintext highlighter-rouge">secret.String</code>, so it redacts itself in <code class="language-plaintext highlighter-rouge">fmt</code>, <code class="language-plaintext highlighter-rouge">slog</code>, and JSON — a stray <code class="language-plaintext highlighter-rouge">log.Printf("%+v", cfg)</code> prints <code class="language-plaintext highlighter-rouge">[REDACTED]</code> — and only reveals plaintext through an explicit <code class="language-plaintext highlighter-rouge">.Reveal()</code>. mamori also ships a <code class="language-plaintext highlighter-rouge">go vet</code> analyzer that fails your build if an <code class="language-plaintext highlighter-rouge">azure-kv://</code> value lands in a plain <code class="language-plaintext highlighter-rouge">string</code> instead of a <code class="language-plaintext highlighter-rouge">secret.String</code>.</p>

<h2 id="what-reconciliation-guarantees">What reconciliation guarantees</h2>

<p>When a new version of <code class="language-plaintext highlighter-rouge">db-password</code> becomes the latest enabled one, mamori’s poll cycle detects the version change, fetches it, and then:</p>

<ol>
  <li><strong>Re-validates the entire struct</strong> — a value that fails a <code class="language-plaintext highlighter-rouge">validate</code> rule is rejected atomically; <code class="language-plaintext highlighter-rouge">Get()</code> keeps returning the last good config and <code class="language-plaintext highlighter-rouge">OnError</code> receives a <code class="language-plaintext highlighter-rouge">*ValidationError</code>. A bad secret can’t break your service.</li>
  <li><strong>Swaps atomically</strong> — <code class="language-plaintext highlighter-rouge">w.Get()</code> moves from old to new in one lock-free store; no reader ever sees a half-updated config.</li>
  <li><strong>Fires a diff-aware callback</strong> — <code class="language-plaintext highlighter-rouge">ev.Fields</code> tells you exactly which fields moved, so you only rebuild what changed.</li>
</ol>

<p>Key Vault has no push channel for “a new version was created”, so mamori <strong>polls</strong> — politely: the interval is jittered by ±20% (tunable with <code class="language-plaintext highlighter-rouge">WithJitter</code>) so a fleet of pods doesn’t hammer the vault on the same tick, and change detection compares the version id before pulling the payload, so an unchanged secret is cheap.</p>

<p>If Key Vault is briefly unreachable, mamori retries with backoff and keeps serving the last good value; wire <code class="language-plaintext highlighter-rouge">WithStale(10*time.Minute)</code> and check for a <code class="language-plaintext highlighter-rouge">*mamori.StaleError</code> in <code class="language-plaintext highlighter-rouge">OnError</code> if you want to page only on a <em>prolonged</em> outage rather than a transient blip.</p>

<h2 id="structured-secrets-with-flatten">Structured secrets with <code class="language-plaintext highlighter-rouge">flatten</code></h2>

<p>Storing a whole connection blob as one JSON secret? Decode it straight into a nested struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DB</span> <span class="n">DBConfig</span> <span class="s">`source:"azure-kv://my-vault/db-conn" flatten:"json"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">DBConfig</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Host</span>     <span class="kt">string</span>        <span class="s">`mapstructure:"host"`</span>
	<span class="n">Port</span>     <span class="kt">int</span>           <span class="s">`mapstructure:"port"`</span>
	<span class="n">Password</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`mapstructure:"password"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One Key Vault call, one JSON document, cleanly typed — with the password still redacting itself.</p>

<h2 id="when-you-dont-need-this">When you don’t need this</h2>

<p>If a secret only ever changes on deploy, a plain <code class="language-plaintext highlighter-rouge">mamori.Load</code> at boot is all you need — no watching, no poll loop. mamori earns its keep the moment a Key Vault secret rotates <em>while your process is running</em> and you’d rather rebuild a client than recycle a pod.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Azure Key Vault rotates your secrets cleanly; mamori makes your Go service actually notice. It watches Key Vault, reconciles a new version into your running process — typed, validated, redacted — and lets you pin the versions you want frozen.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/providers/azure
</code></pre></div></div>

<ul>
  <li><strong>Full introduction:</strong> <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori</a></li>
  <li><strong>Docs:</strong> <a href="https://mamorigo.dev/docs">mamorigo.dev/docs</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/xavidop/mamori">github.com/xavidop/mamori</a></li>
</ul>

<p>mamori is MIT-licensed and at <code class="language-plaintext highlighter-rouge">v0.1.0</code> — early and moving fast, so issues and feedback are very welcome. Happy coding!</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="azure" /><category term="azure" /><category term="go" /><category term="golang" /><category term="mamori" /><category term="secrets" /><category term="key-vault" /><category term="secret-rotation" /><summary type="html"><![CDATA[Azure Key Vault versions your secrets and lets you rotate them behind the scenes, but your Go service resolved the secret once at boot and never looked again. mamori watches Key Vault and reconciles a new version into your running process, typed, validated, and redacted, without a restart.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/mamori-azure.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/mamori-azure.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">GCP Secret Manager, Live-Reloaded in Go with mamori (English)</title><link href="https://xavidop.me/gcp/2026-07-22-mamori-gcp-secret-manager-live-reload-go/" rel="alternate" type="text/html" title="GCP Secret Manager, Live-Reloaded in Go with mamori (English)" /><published>2026-07-22T00:00:00+00:00</published><updated>2026-07-22T16:48:27+00:00</updated><id>https://xavidop.me/gcp/mamori-gcp-secret-manager-live-reload-go</id><content type="html" xml:base="https://xavidop.me/gcp/2026-07-22-mamori-gcp-secret-manager-live-reload-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-gcp-sm-scheme" id="markdown-toc-the-gcp-sm-scheme">The <code class="language-plaintext highlighter-rouge">gcp-sm</code> scheme</a></li>
  <li><a href="#setup" id="markdown-toc-setup">Setup</a></li>
  <li><a href="#load-then-watch-latest-move" id="markdown-toc-load-then-watch-latest-move">Load, then watch <code class="language-plaintext highlighter-rouge">latest</code> move</a></li>
  <li><a href="#what-reconciliation-actually-does-here" id="markdown-toc-what-reconciliation-actually-does-here">What reconciliation actually does here</a></li>
  <li><a href="#structured-secrets-with-flatten" id="markdown-toc-structured-secrets-with-flatten">Structured secrets with <code class="language-plaintext highlighter-rouge">flatten</code></a></li>
  <li><a href="#version-pinning-as-a-deploy-strategy" id="markdown-toc-version-pinning-as-a-deploy-strategy">Version pinning as a deploy strategy</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>Google Cloud Secret Manager has a clean, opinionated model: a <strong>secret</strong> is a container, and inside it live immutable, numbered <strong>versions</strong>. You never edit a version — you <em>add</em> a new one (<code class="language-plaintext highlighter-rouge">1</code>, <code class="language-plaintext highlighter-rouge">2</code>, <code class="language-plaintext highlighter-rouge">3</code>, …) and, when it’s ready, you move traffic to it, usually by resolving the <code class="language-plaintext highlighter-rouge">latest</code> alias (or by disabling the old version).</p>

<p>It’s a genuinely good design. But it collides with how most Go services consume secrets: you call <code class="language-plaintext highlighter-rouge">AccessSecretVersion("latest")</code> exactly once, at startup, and cache the result. From that moment your process is pinned to whatever <code class="language-plaintext highlighter-rouge">latest</code> meant <em>at boot</em>. Add version <code class="language-plaintext highlighter-rouge">4</code>, disable version <code class="language-plaintext highlighter-rouge">3</code>, and your service happily keeps using <code class="language-plaintext highlighter-rouge">3</code> until someone restarts it — which, at best, is a scheduled redeploy and, at worst, an incident when the disabled version stops resolving.</p>

<p><a href="https://mamorigo.dev"><strong>mamori</strong></a> is a Go library I built to make config and secrets <em>watchable</em>. Point it at a GCP secret and it notices when <code class="language-plaintext highlighter-rouge">latest</code> moves, pulls the new version, re-validates your whole config, and swaps it in atomically — <strong>while your service keeps running.</strong></p>

<blockquote>
  <p>This is the GCP-focused companion to the launch article, <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori: Typed, Watchable Config &amp; Secrets for Go</a>. That one covers the full model; here we focus on Secret Manager’s version semantics.</p>
</blockquote>

<h2 id="the-gcp-sm-scheme">The <code class="language-plaintext highlighter-rouge">gcp-sm</code> scheme</h2>

<p>The <code class="language-plaintext highlighter-rouge">providers/gcp</code> module registers the <code class="language-plaintext highlighter-rouge">gcp-sm</code> scheme. The grammar is <code class="language-plaintext highlighter-rouge">gcp-sm://&lt;project&gt;/&lt;secret&gt;[#json-key][?version=&lt;v&gt;]</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gcp-sm://my-project/db-password        # resolves the "latest" enabled version
gcp-sm://my-project/api-key?version=3  # pin to version 3, forever
gcp-sm://my-project/creds#password     # one key out of a JSON secret payload
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">?version=</code> option is the important knob, and it maps directly onto GCP’s version model:</p>

<ul>
  <li><strong>No <code class="language-plaintext highlighter-rouge">?version</code></strong> → mamori tracks <code class="language-plaintext highlighter-rouge">latest</code>. When you add a new version and it becomes <code class="language-plaintext highlighter-rouge">latest</code>, mamori reconciles it in. This is what you want for rotating credentials.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">?version=N</code></strong> → mamori pins to an immutable version <code class="language-plaintext highlighter-rouge">N</code>. It will never move. This is what you want for config you want to promote deliberately and reproducibly (think: “this deploy runs config version 7, full stop”).</li>
</ul>

<h2 id="setup">Setup</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
go get github.com/xavidop/mamori/providers/gcp
</code></pre></div></div>

<p>Register with a blank import; it uses Application Default Credentials, so on GKE it picks up <strong>Workload Identity</strong> automatically, and locally it uses your <code class="language-plaintext highlighter-rouge">gcloud auth application-default</code> login:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/gcp"</span>
</code></pre></div></div>

<p>The service account you bind needs <code class="language-plaintext highlighter-rouge">roles/secretmanager.secretAccessor</code> on the secrets it reads (and the ability to see version metadata to detect a new <code class="language-plaintext highlighter-rouge">latest</code>). Scope it to the specific secrets, not the whole project.</p>

<h2 id="load-then-watch-latest-move">Load, then watch <code class="language-plaintext highlighter-rouge">latest</code> move</h2>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"log"</span>
	<span class="s">"time"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>

	<span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/gcp"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="c">// Tracks "latest": rotates in when you add a new enabled version.</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"gcp-sm://my-project/db-password"`</span>

	<span class="c">// Pinned to an immutable version: deliberate, reproducible.</span>
	<span class="n">StripeKey</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"gcp-sm://my-project/stripe-key?version=7"`</span>

	<span class="c">// One field out of a JSON secret.</span>
	<span class="n">APIToken</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"gcp-sm://my-project/creds#api_token"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">ctx</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">()</span>

	<span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">WithPollInterval</span><span class="p">(</span><span class="m">60</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
			<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"DBPassword"</span><span class="p">)</span> <span class="p">{</span>
				<span class="n">pool</span><span class="o">.</span><span class="n">Rotate</span><span class="p">(</span><span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">DBPassword</span><span class="o">.</span><span class="n">Reveal</span><span class="p">())</span> <span class="c">// live, no restart</span>
				<span class="n">log</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="s">"db-password: new version reconciled"</span><span class="p">)</span>
			<span class="p">}</span>
		<span class="p">}),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_error"</span><span class="p">)</span> <span class="p">}),</span>
	<span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

	<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span> <span class="c">// last VALID config, lock-free</span>
	<span class="n">_</span> <span class="o">=</span> <span class="n">cfg</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every secret field here is a <code class="language-plaintext highlighter-rouge">secret.String</code>, which redacts itself in <code class="language-plaintext highlighter-rouge">fmt</code>, <code class="language-plaintext highlighter-rouge">slog</code>, and JSON — so nothing leaks into logs — and only reveals plaintext through an explicit <code class="language-plaintext highlighter-rouge">.Reveal()</code>. mamori’s <code class="language-plaintext highlighter-rouge">go vet</code> analyzer will even fail your build if you store a <code class="language-plaintext highlighter-rouge">gcp-sm://</code> value in a plain <code class="language-plaintext highlighter-rouge">string</code>.</p>

<h2 id="what-reconciliation-actually-does-here">What reconciliation actually does here</h2>

<p>When you rotate — add version <code class="language-plaintext highlighter-rouge">4</code> to <code class="language-plaintext highlighter-rouge">db-password</code> and let it become <code class="language-plaintext highlighter-rouge">latest</code> — mamori’s poll cycle notices that the resolved version changed, fetches it, and then:</p>

<ol>
  <li><strong>Re-validates the entire struct.</strong> If the new secret somehow fails a <code class="language-plaintext highlighter-rouge">validate</code> rule, the update is rejected: <code class="language-plaintext highlighter-rouge">Get()</code> keeps returning the last good value and <code class="language-plaintext highlighter-rouge">OnError</code> gets a <code class="language-plaintext highlighter-rouge">*ValidationError</code>. A bad version can’t take your service down.</li>
  <li><strong>Swaps atomically.</strong> <code class="language-plaintext highlighter-rouge">w.Get()</code> transitions from old to new in one lock-free store — no reader ever sees a partially-updated config.</li>
  <li><strong>Fires a diff-aware callback.</strong> <code class="language-plaintext highlighter-rouge">OnChange</code> tells you <em>which</em> fields moved (<code class="language-plaintext highlighter-rouge">ev.Fields</code>) with old/new versions, so you only rebuild what actually changed.</li>
</ol>

<p>Because Secret Manager has no push channel for “latest moved”, mamori <strong>polls</strong> — and it polls politely: the interval is jittered by ±20% so a large fleet doesn’t stampede the API on the same tick, and change detection compares version metadata before pulling the payload, so an unchanged secret is cheap.</p>

<h2 id="structured-secrets-with-flatten">Structured secrets with <code class="language-plaintext highlighter-rouge">flatten</code></h2>

<p>Teams often store a whole connection blob as one JSON secret. Decode it straight into a nested struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DB</span> <span class="n">DBConfig</span> <span class="s">`source:"gcp-sm://my-project/db-conn" flatten:"json"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">DBConfig</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Host</span>     <span class="kt">string</span>        <span class="s">`mapstructure:"host"`</span>
	<span class="n">Port</span>     <span class="kt">int</span>           <span class="s">`mapstructure:"port"`</span>
	<span class="n">Password</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`mapstructure:"password"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One <code class="language-plaintext highlighter-rouge">AccessSecretVersion</code> call, one JSON document, cleanly typed — with the password still redacting itself.</p>

<h2 id="version-pinning-as-a-deploy-strategy">Version pinning as a deploy strategy</h2>

<p>The <code class="language-plaintext highlighter-rouge">?version=</code> toggle enables a genuinely nice workflow. Keep operational secrets on <code class="language-plaintext highlighter-rouge">latest</code> so rotations flow in automatically, but pin <em>config that changes behavior</em> to explicit versions so a rollout is deterministic and rollback is trivial:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DBPassword</span>   <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"gcp-sm://my-project/db-password"`</span>             <span class="c">// latest: auto-rotate</span>
	<span class="n">FeatureFlags</span> <span class="p">[]</span><span class="kt">byte</span>        <span class="s">`source:"gcp-sm://my-project/flags?version=12"`</span>         <span class="c">// pinned: promote on purpose</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Bumping <code class="language-plaintext highlighter-rouge">?version=12</code> to <code class="language-plaintext highlighter-rouge">?version=13</code> becomes a reviewable, revertible change in your code — while credentials keep rotating themselves in the background.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>GCP Secret Manager’s immutable-versions model is great, but only if your app actually follows <code class="language-plaintext highlighter-rouge">latest</code> at runtime. mamori makes it do that: it watches the secret, reconciles a new version into your running Go process, validated and redacted, and lets you pin the fields you want frozen.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/providers/gcp
</code></pre></div></div>

<ul>
  <li><strong>Full introduction:</strong> <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori</a></li>
  <li><strong>Docs:</strong> <a href="https://mamorigo.dev/docs">mamorigo.dev/docs</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/xavidop/mamori">github.com/xavidop/mamori</a></li>
</ul>

<p>mamori is MIT-licensed and at <code class="language-plaintext highlighter-rouge">v0.1.0</code> — early and moving fast, so issues and feedback are very welcome. Happy coding!</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="gcp" /><category term="gcp" /><category term="go" /><category term="golang" /><category term="mamori" /><category term="secrets" /><category term="secret-manager" /><category term="secret-rotation" /><summary type="html"><![CDATA[In GCP Secret Manager, secret versions are immutable and you rotate by adding a new version and moving the "latest" alias. But your Go service resolved "latest" once at boot and froze it there. mamori watches the secret, notices when "latest" moves, and reconciles the new version into your running process, validated and redacted.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/mamori-gcp.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/mamori-gcp.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Hot-Reloading Kubernetes Secrets and ConfigMaps in Go with mamori (No Pod Restarts) (English)</title><link href="https://xavidop.me/kubernetes/2026-07-22-mamori-hot-reload-kubernetes-secrets-configmaps-go/" rel="alternate" type="text/html" title="Hot-Reloading Kubernetes Secrets and ConfigMaps in Go with mamori (No Pod Restarts) (English)" /><published>2026-07-22T00:00:00+00:00</published><updated>2026-07-22T16:48:27+00:00</updated><id>https://xavidop.me/kubernetes/mamori-hot-reload-kubernetes-secrets-configmaps-go</id><content type="html" xml:base="https://xavidop.me/kubernetes/2026-07-22-mamori-hot-reload-kubernetes-secrets-configmaps-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-two-schemes-youll-use" id="markdown-toc-the-two-schemes-youll-use">The two schemes you’ll use</a></li>
  <li><a href="#setup" id="markdown-toc-setup">Setup</a></li>
  <li><a href="#load-once-then-watch-forever" id="markdown-toc-load-once-then-watch-forever">Load once, then watch forever</a></li>
  <li><a href="#informers-not-polling" id="markdown-toc-informers-not-polling">Informers, not polling</a></li>
  <li><a href="#whole-configmaps-and-json-blobs" id="markdown-toc-whole-configmaps-and-json-blobs">Whole ConfigMaps and JSON blobs</a></li>
  <li><a href="#a-note-on-tls-certificates" id="markdown-toc-a-note-on-tls-certificates">A note on TLS certificates</a></li>
  <li><a href="#when-you-dont-need-this" id="markdown-toc-when-you-dont-need-this">When you don’t need this</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>If you run Go services on Kubernetes, you know the ritual. You update a <code class="language-plaintext highlighter-rouge">Secret</code> or a <code class="language-plaintext highlighter-rouge">ConfigMap</code>, and then you do one of these:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">kubectl rollout restart deployment/my-app</code> and eat the disruption.</li>
  <li>Install <a href="https://github.com/stakater/Reloader">Reloader</a> so <em>it</em> restarts your pods for you when a <code class="language-plaintext highlighter-rouge">Secret</code> changes.</li>
  <li>Mount the <code class="language-plaintext highlighter-rouge">Secret</code> as a volume and cross your fingers that your app re-reads the file (spoiler: most apps read it once at boot).</li>
</ul>

<p>All three share the same flaw: the way you “pick up” a config change is to <strong>throw away the process and start a new one.</strong> For a rotating database password, that means dropping every in-flight request, cold connection pools, and a latency spike — all to change one string.</p>

<p>I built <a href="https://mamorigo.dev"><strong>mamori</strong></a> partly because of this exact frustration. mamori watches your Kubernetes <code class="language-plaintext highlighter-rouge">Secret</code>s and <code class="language-plaintext highlighter-rouge">ConfigMap</code>s with native <strong>informers</strong>, and when a value changes it reconciles the new value into your running Go process — typed, validated, and atomically swapped — <strong>without restarting anything.</strong></p>

<blockquote>
  <p>New to mamori? Start with the launch article, <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori: Typed, Watchable Config &amp; Secrets for Go</a>, for the full picture. This post is the Kubernetes-focused deep cut.</p>
</blockquote>

<h2 id="the-two-schemes-youll-use">The two schemes you’ll use</h2>

<p>mamori’s Kubernetes provider gives you two ref schemes:</p>

<table>
  <thead>
    <tr>
      <th>Ref</th>
      <th>Reads from</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">k8s-secret://&lt;namespace&gt;/&lt;name&gt;#&lt;key&gt;</code></td>
      <td>a <code class="language-plaintext highlighter-rouge">Secret</code></td>
      <td>value is <strong>base64-decoded for you</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">k8s-cm://&lt;namespace&gt;/&lt;name&gt;#&lt;key&gt;</code></td>
      <td>a <code class="language-plaintext highlighter-rouge">ConfigMap</code></td>
      <td>plain string values</td>
    </tr>
  </tbody>
</table>

<p>Drop the <code class="language-plaintext highlighter-rouge">#key</code> and you get the whole <code class="language-plaintext highlighter-rouge">Secret</code>/<code class="language-plaintext highlighter-rouge">ConfigMap</code> as a JSON map, which you can decode into a nested struct with <code class="language-plaintext highlighter-rouge">flatten</code>. A few concrete examples:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k8s-secret://prod/db-creds#password   # one key from a Secret, base64-decoded
k8s-secret://prod/tls#ca.crt          # a certificate as []byte
k8s-cm://prod/app-config#log_level    # one key from a ConfigMap
k8s-cm://prod/app-config              # the whole ConfigMap as a JSON map
</code></pre></div></div>

<h2 id="setup">Setup</h2>

<p>The provider lives in its own module. Note the import path is <code class="language-plaintext highlighter-rouge">providers/k8s</code> (not <code class="language-plaintext highlighter-rouge">providers/kubernetes</code>):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
go get github.com/xavidop/mamori/providers/k8s
</code></pre></div></div>

<p>Register it with a blank import — same convention as <code class="language-plaintext highlighter-rouge">database/sql</code> drivers. Its <code class="language-plaintext highlighter-rouge">init()</code> wires the <code class="language-plaintext highlighter-rouge">k8s-secret</code> and <code class="language-plaintext highlighter-rouge">k8s-cm</code> schemes into mamori:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/k8s"</span>
</code></pre></div></div>

<p>In-cluster, the provider uses the pod’s mounted ServiceAccount automatically. The only thing you owe it is <strong>RBAC</strong>: it needs to <code class="language-plaintext highlighter-rouge">get</code>, <code class="language-plaintext highlighter-rouge">list</code>, and <code class="language-plaintext highlighter-rouge">watch</code> the resources you reference in the namespaces you reference.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Role</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">mamori-reader</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">prod</span>
<span class="na">rules</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">apiGroups</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">"</span><span class="pi">]</span>
    <span class="na">resources</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">secrets"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">configmaps"</span><span class="pi">]</span>
    <span class="na">verbs</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">get"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">list"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">watch"</span><span class="pi">]</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">RoleBinding</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">mamori-reader</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">prod</span>
<span class="na">subjects</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">my-app</span>
    <span class="na">namespace</span><span class="pi">:</span> <span class="s">prod</span>
<span class="na">roleRef</span><span class="pi">:</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">Role</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">mamori-reader</span>
  <span class="na">apiGroup</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">watch</code> verb is the important one — it’s what lets mamori use an informer instead of hammering the API server with polls.</p>

<h2 id="load-once-then-watch-forever">Load once, then watch forever</h2>

<p>Describe what you need as a struct. mamori resolves every ref, applies defaults, and validates — all before your <code class="language-plaintext highlighter-rouge">main</code> gets a config:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"log"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>

	<span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/k8s"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"k8s-secret://prod/db-creds#password"`</span>
	<span class="n">LogLevel</span>   <span class="kt">string</span>        <span class="s">`source:"k8s-cm://prod/app-config#log_level" default:"info" validate:"oneof=debug info warn error"`</span>
	<span class="n">MaxConns</span>   <span class="kt">int</span>           <span class="s">`source:"k8s-cm://prod/app-config#max_conns" default:"10" validate:"gte=1,lte=500"`</span>
	<span class="n">CACert</span>     <span class="p">[]</span><span class="kt">byte</span>        <span class="s">`source:"k8s-secret://prod/tls#ca.crt"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">cfg</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Load</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="n">_</span> <span class="o">=</span> <span class="n">cfg</span> <span class="c">// fully resolved, defaulted, validated</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice <code class="language-plaintext highlighter-rouge">DBPassword</code> is a <code class="language-plaintext highlighter-rouge">secret.String</code>, not a plain <code class="language-plaintext highlighter-rouge">string</code>. That type redacts itself in logs, <code class="language-plaintext highlighter-rouge">fmt</code>, and JSON — so a stray <code class="language-plaintext highlighter-rouge">log.Printf("%+v", cfg)</code> prints <code class="language-plaintext highlighter-rouge">[REDACTED]</code> instead of leaking the password. You only ever see the plaintext when you explicitly call <code class="language-plaintext highlighter-rouge">.Reveal()</code>.</p>

<p>Now the part that matters. Swap <code class="language-plaintext highlighter-rouge">Load</code> for <code class="language-plaintext highlighter-rouge">Watch</code>, and the moment someone runs <code class="language-plaintext highlighter-rouge">kubectl edit secret db-creds</code>, your process reacts:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"DBPassword"</span><span class="p">)</span> <span class="p">{</span>
			<span class="c">// Rebuild the pool with the rotated password — live, no restart.</span>
			<span class="n">pool</span><span class="o">.</span><span class="n">Rotate</span><span class="p">(</span><span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">DBPassword</span><span class="o">.</span><span class="n">Reveal</span><span class="p">())</span>
		<span class="p">}</span>
		<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">f</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">ev</span><span class="o">.</span><span class="n">Fields</span> <span class="p">{</span>
			<span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"reconciled %s: %s -&gt; %s"</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">OldVersion</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">NewVersion</span><span class="p">)</span>
		<span class="p">}</span>
	<span class="p">}),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_error"</span><span class="p">)</span> <span class="p">}),</span>
<span class="p">)</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
	<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span> <span class="c">// lock-free snapshot; always the last VALID config</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">w.Get()</code> is lock-free and always returns the last <em>valid</em> configuration. If someone pushes a <code class="language-plaintext highlighter-rouge">ConfigMap</code> with <code class="language-plaintext highlighter-rouge">log_level: banana</code>, the <code class="language-plaintext highlighter-rouge">validate:"oneof=..."</code> tag rejects it: the update is dropped atomically, <code class="language-plaintext highlighter-rouge">Get()</code> keeps returning the good value, and <code class="language-plaintext highlighter-rouge">OnError</code> receives a <code class="language-plaintext highlighter-rouge">*ValidationError</code>. <strong>A bad edit never takes your service into a broken state.</strong></p>

<h2 id="informers-not-polling">Informers, not polling</h2>

<p>This is the difference between mamori and the mount-a-file-and-poll approach. The Kubernetes provider implements native watching backed by the client-go informer machinery, so change detection is <strong>push-based</strong> — the API server tells your process the instant a <code class="language-plaintext highlighter-rouge">Secret</code> changes, rather than your process asking every N seconds.</p>

<p>Practically, that means:</p>

<ul>
  <li><strong>Near-instant reconciliation.</strong> No polling interval to tune, no <code class="language-plaintext highlighter-rouge">min(sync, interval)</code> latency floor like the kubelet’s ~60s volume refresh.</li>
  <li><strong>No API-server hammering.</strong> One watch connection per resource, shared, instead of a <code class="language-plaintext highlighter-rouge">GET</code> loop per pod.</li>
  <li><strong>Graceful fallback.</strong> If a native watch fails to start for some reason, mamori automatically falls back to polling with jittered intervals, so you never lose reconciliation entirely.</li>
</ul>

<p>Compare that to the alternatives:</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>How a change is picked up</th>
      <th>Restart?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">kubectl rollout restart</code></td>
      <td>you, manually</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Reloader / Stakater</td>
      <td>operator restarts the pod</td>
      <td>yes</td>
    </tr>
    <tr>
      <td>Secret mounted as volume</td>
      <td>kubelet re-syncs file (~60s), <em>if</em> your app re-reads it</td>
      <td>no, but you must re-read</td>
    </tr>
    <tr>
      <td><strong>mamori</strong></td>
      <td>informer pushes change → validated atomic swap → callback</td>
      <td><strong>no</strong></td>
    </tr>
  </tbody>
</table>

<h2 id="whole-configmaps-and-json-blobs">Whole ConfigMaps and JSON blobs</h2>

<p>Sometimes you keep a structured blob in one key. Decode it straight into a nested struct with <code class="language-plaintext highlighter-rouge">flatten</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Redis</span> <span class="n">RedisConfig</span> <span class="s">`source:"k8s-secret://prod/redis-conn" flatten:"json"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">RedisConfig</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Addr</span>     <span class="kt">string</span>        <span class="s">`mapstructure:"addr"`</span>
	<span class="n">Password</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`mapstructure:"password"`</span>
	<span class="n">DB</span>       <span class="kt">int</span>           <span class="s">`mapstructure:"db"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That reads the whole <code class="language-plaintext highlighter-rouge">redis-conn</code> Secret (a JSON document), decodes it, and — because <code class="language-plaintext highlighter-rouge">Password</code> is a <code class="language-plaintext highlighter-rouge">secret.String</code> — keeps the password redacted throughout.</p>

<h2 id="a-note-on-tls-certificates">A note on TLS certificates</h2>

<p>Certificates rotate too, and a cert reload is the perfect no-restart use case. Because file and Secret updates for certs usually want to apply <em>immediately</em> (no coalescing delay), you can set <code class="language-plaintext highlighter-rouge">?debounce=0</code> on the ref:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">TLS</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Cert</span> <span class="p">[]</span><span class="kt">byte</span>       <span class="s">`source:"k8s-secret://prod/tls#tls.crt?debounce=0"`</span>
	<span class="n">Key</span>  <span class="n">secret</span><span class="o">.</span><span class="n">Bytes</span> <span class="s">`source:"k8s-secret://prod/tls#tls.key?debounce=0"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Wire the <code class="language-plaintext highlighter-rouge">OnChange</code> callback to swap the <code class="language-plaintext highlighter-rouge">*tls.Certificate</code> your server hands out in its <code class="language-plaintext highlighter-rouge">GetCertificate</code> hook, and you’ve got zero-downtime cert rotation from a Kubernetes <code class="language-plaintext highlighter-rouge">Secret</code>.</p>

<h2 id="when-you-dont-need-this">When you don’t need this</h2>

<p>Be honest with yourself: if your config is a log level from a <code class="language-plaintext highlighter-rouge">ConfigMap</code> that only ever changes on deploy, you don’t need runtime watching — a plain <code class="language-plaintext highlighter-rouge">Load</code> at boot is fine, and mamori is happy to do just that. And if you already mount a <code class="language-plaintext highlighter-rouge">Secret</code> as a file and genuinely re-read it, mamori’s built-in <code class="language-plaintext highlighter-rouge">file://</code> provider (with <code class="language-plaintext highlighter-rouge">fsnotify</code>) covers that too, without any Kubernetes RBAC at all.</p>

<p>mamori earns its keep the moment a value changes <em>while your process is running</em> and you’d rather rotate a pool than recycle a pod.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Kubernetes already tells you the instant a <code class="language-plaintext highlighter-rouge">Secret</code> changes — via informers. mamori just connects that signal to a typed, validated, redacted struct inside your Go process, so a rotation becomes a callback instead of a rollout.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/providers/k8s
</code></pre></div></div>

<ul>
  <li><strong>Full introduction:</strong> <a href="/go/2026-07-21-introducing-mamori-config-secrets-go/">Introducing mamori</a></li>
  <li><strong>Docs:</strong> <a href="https://mamorigo.dev/docs">mamorigo.dev/docs</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/xavidop/mamori">github.com/xavidop/mamori</a></li>
</ul>

<p>mamori is MIT-licensed and currently at <code class="language-plaintext highlighter-rouge">v0.1.0</code>, so issues and feedback are very welcome. Happy shipping!</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="kubernetes" /><category term="kubernetes" /><category term="go" /><category term="golang" /><category term="mamori" /><category term="secrets" /><category term="configmap" /><category term="secret-rotation" /><summary type="html"><![CDATA[You updated a Kubernetes Secret and then did the ritual: kubectl rollout restart. There is a better way. mamori watches your Secrets and ConfigMaps with native informers and reconciles the change into your running Go process, atomically and validated, without bouncing a single pod.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/mamori-kubernetes.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/mamori-kubernetes.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Introducing mamori: Typed, Watchable Config &amp;amp; Secrets for Go (English)</title><link href="https://xavidop.me/go/2026-07-21-introducing-mamori-config-secrets-go/" rel="alternate" type="text/html" title="Introducing mamori: Typed, Watchable Config &amp;amp; Secrets for Go (English)" /><published>2026-07-21T00:00:00+00:00</published><updated>2026-07-22T16:48:27+00:00</updated><id>https://xavidop.me/go/introducing-mamori-config-secrets-go</id><content type="html" xml:base="https://xavidop.me/go/2026-07-21-introducing-mamori-config-secrets-go/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-restart-to-rotate-problem" id="markdown-toc-the-restart-to-rotate-problem">The restart-to-rotate problem</a></li>
  <li><a href="#what-is-mamori" id="markdown-toc-what-is-mamori">What is mamori?</a></li>
  <li><a href="#how-mamori-compares" id="markdown-toc-how-mamori-compares">How mamori compares</a></li>
  <li><a href="#getting-started" id="markdown-toc-getting-started">Getting started</a></li>
  <li><a href="#watching-and-reconciliation" id="markdown-toc-watching-and-reconciliation">Watching and reconciliation</a></li>
  <li><a href="#secrets-that-behave-like-secrets" id="markdown-toc-secrets-that-behave-like-secrets">Secrets that behave like secrets</a></li>
  <li><a href="#providers-more-than-30-sources" id="markdown-toc-providers-more-than-30-sources">Providers: more than 30 sources</a></li>
  <li><a href="#writing-your-own-provider" id="markdown-toc-writing-your-own-provider">Writing your own provider</a></li>
  <li><a href="#middleware-cache-failover-audit-rate-limit" id="markdown-toc-middleware-cache-failover-audit-rate-limit">Middleware: cache, failover, audit, rate-limit</a></li>
  <li><a href="#observability-out-of-the-box" id="markdown-toc-observability-out-of-the-box">Observability out of the box</a></li>
  <li><a href="#a-complete-runnable-example" id="markdown-toc-a-complete-runnable-example">A complete, runnable example</a></li>
  <li><a href="#when-to-reach-for-mamori-and-when-not" id="markdown-toc-when-to-reach-for-mamori-and-when-not">When to reach for mamori (and when not)</a></li>
  <li><a href="#wrapping-up" id="markdown-toc-wrapping-up">Wrapping up</a>    <ol>
      <li><a href="#provider-deep-dives" id="markdown-toc-provider-deep-dives">Provider deep-dives</a></li>
    </ol>
  </li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>Every Go service I have ever shipped ends up growing the same organ. It starts small: a couple of <code class="language-plaintext highlighter-rouge">os.Getenv</code> calls. Then a secret needs to come from AWS Secrets Manager, so you add the SDK. Then someone wants that secret to rotate without a deploy, so you add a <code class="language-plaintext highlighter-rouge">time.Ticker</code>. Then the ticker needs a mutex because two goroutines read the config while it is being replaced. Then a bad value slips in and takes down production, so you add validation. Then a secret shows up in a log line during an incident, and now you are adding a redaction wrapper too.</p>

<p>By the end you have a bespoke <code class="language-plaintext highlighter-rouge">ConfigManager</code> in every repo: a loader, a fetcher, a refresh loop, a mutex, and a prayer. It is never quite the same twice, it is rarely tested well, and it is exactly the kind of code nobody wants to own.</p>

<p>I got tired of writing it, so I built a library that does it once, properly. It is called <strong><a href="https://mamorigo.dev">mamori</a></strong>, it is open source, and this article is the full tour: what it is, why it exists, how to use it end to end, and when you should (and shouldn’t) reach for it.</p>

<h2 id="the-restart-to-rotate-problem">The restart-to-rotate problem</h2>

<p>Here is the assumption baked into most Go config code: <strong>configuration is static</strong>. You read it at boot, decode it into a struct, and treat that struct as immutable for the lifetime of the process. If something changes, you redeploy.</p>

<p>That assumption was fine when “config” meant a log level and a port. It is not fine anymore. In a modern service, a meaningful amount of your config is <em>dynamic by nature</em>:</p>

<ul>
  <li>Database passwords and API tokens rotate on a schedule (and increasingly, automatically).</li>
  <li>Vault hands you <strong>dynamic credentials</strong> with a lease that expires in an hour.</li>
  <li>Feature flags flip in the middle of the day, on purpose.</li>
  <li>TLS certificates get reissued.</li>
</ul>

<p>The industry answer to “config is dynamic” has mostly been to push the problem into the platform. On Kubernetes, the <a href="https://external-secrets.io/">External Secrets Operator</a> syncs your cloud secret into a <code class="language-plaintext highlighter-rouge">Secret</code>, and then… you still restart the pod to pick it up, or you mount it as a file and hope your app re-reads it. The dynamic part gets flattened back into a static one at the worst possible layer.</p>

<p>mamori takes the opposite position: <strong>your process should be able to observe a change at the source and react to it, in memory, without bouncing.</strong> Rotating a password should rotate a connection pool, not recycle a pod.</p>

<h2 id="what-is-mamori">What is mamori?</h2>

<p><strong>mamori</strong> (守り, Japanese for <em>“protection”</em> or <em>“safeguard”</em>) is an embedded Go library — not an operator, not a server, not a sidecar. It lives inside your process. Its tagline says it plainly:</p>

<blockquote>
  <p><strong>Typed, watchable config &amp; secrets for Go.</strong> Load configuration and secrets from anywhere into validated Go structs — and keep them reconciled at runtime, without a restart.</p>
</blockquote>

<p>There are three concepts you actually touch, and they map cleanly onto three types:</p>

<ol>
  <li>
    <p><strong>Refs.</strong> Struct fields carry a <code class="language-plaintext highlighter-rouge">source</code> tag containing a URL-like reference to where the value lives — <code class="language-plaintext highlighter-rouge">env:LOG_LEVEL</code>, <code class="language-plaintext highlighter-rouge">aws-sm://prod/db#password</code>, <code class="language-plaintext highlighter-rouge">vault://secret/app#token</code>. A ref names <em>what</em> you want, not <em>how</em> to fetch it.</p>
  </li>
  <li>
    <p><strong>Providers.</strong> A provider resolves one scheme (<code class="language-plaintext highlighter-rouge">aws-sm</code>, <code class="language-plaintext highlighter-rouge">vault</code>, <code class="language-plaintext highlighter-rouge">k8s-secret</code>, …) into a value. Providers register themselves using the same pattern as <code class="language-plaintext highlighter-rouge">database/sql</code> drivers, so the core module carries <strong>zero cloud-SDK dependencies</strong> — you only pull in the providers you use.</p>
  </li>
  <li>
    <p><strong>The Reconciler.</strong> This is the interesting part. <code class="language-plaintext highlighter-rouge">Watch</code> resolves every ref once (fail-fast at startup), then keeps watching each source. When a value changes, it <strong>re-validates the entire struct</strong>, and only if the new snapshot is valid does it <strong>atomically swap it in</strong> and hand you a diff-aware callback. Your app never sees a half-updated, invalid config.</p>
  </li>
</ol>

<p>If you have used <code class="language-plaintext highlighter-rouge">IOptionsMonitor&lt;T&gt;</code> in .NET, <code class="language-plaintext highlighter-rouge">w.Get()</code> is the direct analogue: always the latest <em>valid</em> configuration, lock-free.</p>

<h2 id="how-mamori-compares">How mamori compares</h2>

<p>I want to be honest about prior art, because none of these primitives are new — mamori’s contribution is composing them.</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">gocloud.dev/runtimevar</code></strong> is the closest primitive. It watches a single variable beautifully, but it is one variable at a time, with no struct composition, no tags, no validation, no secret hygiene. mamori adds all of that on top of the same idea.</li>
  <li><strong>Viper / koanf</strong> are excellent, but they are <em>config-first with secrets bolted on</em>. mamori is deliberately <em>secrets-first, with config included</em>: secret types redact by default, and there is a <code class="language-plaintext highlighter-rouge">go vet</code> analyzer that yells at you if you store a secret in a plain <code class="language-plaintext highlighter-rouge">string</code>.</li>
  <li><strong>envconfig / caarlos0/env</strong> are load-once. No watching, env only.</li>
  <li><strong>The External Secrets Operator</strong> is complementary, not competitive. mamori is for apps that want to skip the Kubernetes <code class="language-plaintext highlighter-rouge">Secret</code> hop entirely, or that do not run on Kubernetes at all. It keeps no persistent external state, so there is no finalizer lifecycle to babysit.</li>
</ul>

<p>And to be equally clear about what mamori is <strong>not</strong>: it is not a secrets store (no encryption at rest, no server), not a sync engine between stores, not a general feature-flag platform, and not cross-language. It is a Go-idiomatic library and it stays in its lane.</p>

<h2 id="getting-started">Getting started</h2>

<p>The core module has built-in <code class="language-plaintext highlighter-rouge">env:</code>, <code class="language-plaintext highlighter-rouge">file://</code>, and <code class="language-plaintext highlighter-rouge">dotenv:</code> providers, so you can do something useful with zero extra dependencies:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
</code></pre></div></div>

<blockquote>
  <p><strong>Heads up:</strong> mamori requires <strong>Go 1.26 or newer</strong>. It leans on modern generics ergonomics, so this is a hard floor, not a suggestion.</p>
</blockquote>

<p>The whole API is driven by a struct and its tags. You describe <em>what</em> you want; mamori figures out <em>how</em>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"log"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>
<span class="p">)</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">DBPassword</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"aws-sm://prod/db#password"`</span>
	<span class="n">LogLevel</span>   <span class="kt">string</span>        <span class="s">`source:"env:LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"`</span>
	<span class="n">Workers</span>    <span class="kt">int</span>           <span class="s">`source:"env:WORKERS" default:"4" validate:"gte=1,lte=256"`</span>
	<span class="n">TLSCert</span>    <span class="p">[]</span><span class="kt">byte</span>        <span class="s">`source:"file:///etc/tls/tls.crt"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">cfg</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Load</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="c">// cfg is fully resolved, defaulted, and validated.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The signature is exactly what you’d hope for:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">Load</span><span class="p">[</span><span class="n">T</span> <span class="n">any</span><span class="p">](</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">opts</span> <span class="o">...</span><span class="n">Option</span><span class="p">)</span> <span class="p">(</span><span class="n">T</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Load</code> resolves every ref once, applies defaults, validates, and returns your typed config. It <strong>fails fast</strong>: on any resolve or validation error it returns the zero value and a non-nil error. It never returns a partially-populated struct — you either get a complete, valid config or an error.</p>

<p>A few things worth knowing about the tags:</p>

<table>
  <thead>
    <tr>
      <th>Tag</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">source:"..."</code></td>
      <td>The ref: where the value comes from.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">default:"..."</code></td>
      <td>Used only when the ref resolves to <strong>not-found</strong> — <em>never</em> on an error.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">validate:"..."</code></td>
      <td><a href="https://github.com/go-playground/validator">go-playground/validator/v10</a> syntax, run on load and on every update.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flatten:"json\|yaml\|env"</code></td>
      <td>Decode one payload into a nested struct.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">optional:"true"</code></td>
      <td>Tolerate not-found with no default (field keeps its zero value).</td>
    </tr>
  </tbody>
</table>

<p>That <code class="language-plaintext highlighter-rouge">default</code>-only-on-not-found rule is deliberate and important: if AWS Secrets Manager is having a bad day, you want an error, not a silent fallback to a default password.</p>

<h2 id="watching-and-reconciliation">Watching and reconciliation</h2>

<p>This is why mamori exists. Swap <code class="language-plaintext highlighter-rouge">Load</code> for <code class="language-plaintext highlighter-rouge">Watch</code> and your config becomes a living thing:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">Watch</span><span class="p">[</span><span class="n">T</span> <span class="n">any</span><span class="p">](</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">opts</span> <span class="o">...</span><span class="n">Option</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">Watcher</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p>You read the current config with a lock-free <code class="language-plaintext highlighter-rouge">w.Get()</code>, and you react to changes with an <code class="language-plaintext highlighter-rouge">OnChange</code> callback:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithPollInterval</span><span class="p">(</span><span class="m">30</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
		<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"DBPassword"</span><span class="p">)</span> <span class="p">{</span>
			<span class="n">pool</span><span class="o">.</span><span class="n">Rotate</span><span class="p">(</span><span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">DBPassword</span><span class="o">.</span><span class="n">Reveal</span><span class="p">())</span>
		<span class="p">}</span>
		<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">f</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">ev</span><span class="o">.</span><span class="n">Fields</span> <span class="p">{</span>
			<span class="n">log</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"%s changed: %s -&gt; %s"</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">OldVersion</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">NewVersion</span><span class="p">)</span>
		<span class="p">}</span>
	<span class="p">}),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_error"</span><span class="p">)</span> <span class="p">}),</span>
<span class="p">)</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
	<span class="n">log</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">defer</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span>

<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span> <span class="c">// always the last VALID config, no lock needed</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Change[T]</code> you get in the callback carries the old value, the new value, and a per-field diff, plus a <code class="language-plaintext highlighter-rouge">Changed(path)</code> helper so you only do expensive work (like rebuilding a connection pool) when the field you care about actually moved.</p>

<p>The guarantees are the whole point, so let me spell them out:</p>

<ul>
  <li><strong>Atomicity.</strong> <code class="language-plaintext highlighter-rouge">OnChange</code> only ever fires with a fully re-validated snapshot. If an incoming value fails validation, the update is <em>rejected</em>: <code class="language-plaintext highlighter-rouge">Get()</code> keeps returning the last good config and <code class="language-plaintext highlighter-rouge">OnError</code> receives a <code class="language-plaintext highlighter-rouge">*ValidationError</code>. Your config never transitions into a broken state mid-flight.</li>
  <li><strong>Push where possible, poll where not.</strong> Providers whose backend can push changes (Kubernetes informers, Vault lease watchers, Consul blocking queries, <code class="language-plaintext highlighter-rouge">fsnotify</code> for files) push natively. Everything else is polled on <code class="language-plaintext highlighter-rouge">WithPollInterval</code>, <strong>jittered by ±20% by default</strong> so a fleet of pods doesn’t stampede your secrets backend on the same tick. If a native watch fails to start, mamori quietly falls back to polling.</li>
  <li><strong>Coalescing.</strong> Rapid-fire changes within a debounce window (500ms default, overridable per-field with <code class="language-plaintext highlighter-rouge">?debounce=</code>) collapse into a single event.</li>
  <li><strong>Lease-aware.</strong> If a provider reports an expiry (Vault dynamic creds), mamori schedules a refresh <em>before</em> the lease dies, rather than waiting for the next poll.</li>
  <li><strong>First event.</strong> <code class="language-plaintext highlighter-rouge">Watch</code> resolves the initial config before it returns, so <code class="language-plaintext highlighter-rouge">OnChange</code> fires only on <em>subsequent</em> changes. Read the initial state from <code class="language-plaintext highlighter-rouge">w.Get()</code>.</li>
</ul>

<p>There’s a nice operational escape hatch too. Transient backend outages shouldn’t page anyone — but <em>prolonged</em> staleness should. <code class="language-plaintext highlighter-rouge">WithStale</code> draws that line for you:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithStale</span><span class="p">(</span><span class="m">10</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Minute</span><span class="p">),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">var</span> <span class="n">stale</span> <span class="o">*</span><span class="n">mamori</span><span class="o">.</span><span class="n">StaleError</span>
		<span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">As</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">stale</span><span class="p">)</span> <span class="p">{</span>
			<span class="n">alert</span><span class="o">.</span><span class="n">Page</span><span class="p">(</span><span class="s">"config stale"</span><span class="p">,</span> <span class="n">stale</span><span class="p">)</span>
			<span class="k">return</span>
		<span class="p">}</span>
		<span class="n">metrics</span><span class="o">.</span><span class="n">Inc</span><span class="p">(</span><span class="s">"config_transient_error"</span><span class="p">)</span>
	<span class="p">}),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>A blip retries with exponential backoff and keeps the last good value. Ten minutes of failure escalates to a hard <code class="language-plaintext highlighter-rouge">*StaleError</code> you can actually page on.</p>

<h2 id="secrets-that-behave-like-secrets">Secrets that behave like secrets</h2>

<p>Configuration and secrets are treated differently, on purpose. Any field that holds a secret should be a <code class="language-plaintext highlighter-rouge">secret.String</code> or <code class="language-plaintext highlighter-rouge">secret.Bytes</code> instead of a plain <code class="language-plaintext highlighter-rouge">string</code> or <code class="language-plaintext highlighter-rouge">[]byte</code>. These types redact themselves <em>everywhere</em> a value normally leaks:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">s</span> <span class="o">:=</span> <span class="n">secret</span><span class="o">.</span><span class="n">NewString</span><span class="p">(</span><span class="s">"hunter2"</span><span class="p">)</span>

<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>              <span class="c">// [REDACTED]</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"%v"</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span>        <span class="c">// [REDACTED]</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"%#v"</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span>       <span class="c">// [REDACTED]</span>
<span class="n">json</span><span class="o">.</span><span class="n">Marshal</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>             <span class="c">// "[REDACTED]"</span>
<span class="n">slog</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"login"</span><span class="p">,</span> <span class="s">"pw"</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="c">// pw=[REDACTED]</span>

<span class="n">s</span><span class="o">.</span><span class="n">Reveal</span><span class="p">()</span>                  <span class="c">// "hunter2"  &lt;- the only way to read the plaintext</span>
</code></pre></div></div>

<p>It works because the type implements <code class="language-plaintext highlighter-rouge">fmt.Stringer</code>, <code class="language-plaintext highlighter-rouge">fmt.GoStringer</code>, <code class="language-plaintext highlighter-rouge">json.Marshaler</code>, and <code class="language-plaintext highlighter-rouge">slog.LogValuer</code>. The single, greppable access point is <code class="language-plaintext highlighter-rouge">Reveal()</code>. That is a feature: in code review or an audit, <code class="language-plaintext highlighter-rouge">grep Reveal</code> shows you every place a secret is actually unwrapped. (<code class="language-plaintext highlighter-rouge">secret.Bytes.Reveal()</code> returns <code class="language-plaintext highlighter-rouge">[]byte</code> rather than a string, for keys and certificates.)</p>

<p>And because “just remember to use <code class="language-plaintext highlighter-rouge">secret.String</code>” is exactly the kind of discipline that erodes under deadline pressure, mamori ships a <code class="language-plaintext highlighter-rouge">go vet</code> analyzer that enforces it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go <span class="nb">install </span>github.com/xavidop/mamori/tools/reconcilevet/cmd/reconcilevet@latest
go vet <span class="nt">-vettool</span><span class="o">=</span><span class="si">$(</span>which reconcilevet<span class="si">)</span> ./...
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">reconcilevet</code> flags any field whose <code class="language-plaintext highlighter-rouge">source</code> points at a secret-bearing scheme (<code class="language-plaintext highlighter-rouge">aws-sm</code>, <code class="language-plaintext highlighter-rouge">gcp-sm</code>, <code class="language-plaintext highlighter-rouge">azure-kv</code>, <code class="language-plaintext highlighter-rouge">vault</code>, <code class="language-plaintext highlighter-rouge">op</code>, <code class="language-plaintext highlighter-rouge">sops</code>, <code class="language-plaintext highlighter-rouge">k8s-secret</code>) but is stored in a plain <code class="language-plaintext highlighter-rouge">string</code> or <code class="language-plaintext highlighter-rouge">[]byte</code>. The leak becomes a build failure, not a postmortem.</p>

<h2 id="providers-more-than-30-sources">Providers: more than 30 sources</h2>

<p>The core stays dependency-free; each cloud backend is its own module you opt into. A ref’s scheme picks the provider:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/providers/aws         <span class="c"># aws-sm://  aws-ps://</span>
go get github.com/xavidop/mamori/providers/vault       <span class="c"># vault://</span>
go get github.com/xavidop/mamori/providers/gcp         <span class="c"># gcp-sm://</span>
go get github.com/xavidop/mamori/providers/azure       <span class="c"># azure-kv://</span>
go get github.com/xavidop/mamori/providers/k8s         <span class="c"># k8s-secret://  k8s-cm://</span>
go get github.com/xavidop/mamori/providers/consul      <span class="c"># consul://</span>
go get github.com/xavidop/mamori/providers/doppler     <span class="c"># doppler://</span>
go get github.com/xavidop/mamori/providers/onepassword <span class="c"># op://</span>
go get github.com/xavidop/mamori/providers/sops        <span class="c"># sops://</span>
</code></pre></div></div>

<p>That is a small slice. All in, there are <strong>more than 30 providers</strong>: secret managers (AWS Secrets Manager, AWS Parameter Store, Vault, GCP, Azure Key Vault, Doppler, 1Password, SOPS), databases (Postgres, MySQL, SQLite, MongoDB, DynamoDB, Cosmos DB, Redis), KV stores (Consul, etcd), Kubernetes Secrets and ConfigMaps, Firebase/Firestore/Remote Config, object storage (S3, GCS, Azure Blob), and a whole shelf of feature-flag platforms (LaunchDarkly, Unleash, Flagsmith, ConfigCat, Split, GrowthBook, Flipt, GO Feature Flag).</p>

<p>Registration follows the <code class="language-plaintext highlighter-rouge">database/sql</code> convention — a blank import runs the provider’s <code class="language-plaintext highlighter-rouge">init()</code>, which calls <code class="language-plaintext highlighter-rouge">mamori.Register(...)</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">_</span> <span class="s">"github.com/xavidop/mamori/providers/vault"</span>
</code></pre></div></div>

<p>When you need explicit configuration (a region, a custom client), construct the provider and pass it with <code class="language-plaintext highlighter-rouge">WithProvider</code>. That takes precedence over the registry for that call:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="n">awsprov</span> <span class="s">"github.com/xavidop/mamori/providers/aws"</span>

<span class="n">cfg</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Load</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithProvider</span><span class="p">(</span><span class="n">awsprov</span><span class="o">.</span><span class="n">NewSecretsManager</span><span class="p">(</span><span class="n">awsprov</span><span class="o">.</span><span class="n">WithRegion</span><span class="p">(</span><span class="s">"eu-west-1"</span><span class="p">))),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The ref grammar is compact — <code class="language-plaintext highlighter-rouge">scheme://path[#key][?opts]</code> — with a couple of quirks worth internalizing:</p>

<ul>
  <li>The <code class="language-plaintext highlighter-rouge">#key</code> fragment selects one field out of a JSON payload: <code class="language-plaintext highlighter-rouge">aws-sm://prod/db#password</code>.</li>
  <li>AWS Parameter Store, <code class="language-plaintext highlighter-rouge">etcd</code>, <code class="language-plaintext highlighter-rouge">sops</code>, and <code class="language-plaintext highlighter-rouge">file</code> preserve absolute paths, so they use a <strong>triple slash</strong>: <code class="language-plaintext highlighter-rouge">aws-ps:///myapp/log-level</code>, <code class="language-plaintext highlighter-rouge">file:///etc/tls/tls.crt</code>.</li>
  <li>Doppler is the one provider where the <code class="language-plaintext highlighter-rouge">#</code> fragment is <strong>required</strong>: <code class="language-plaintext highlighter-rouge">doppler://backend/prd#STRIPE_API_KEY</code>.</li>
</ul>

<p>When a payload is a structured blob, you can decode it straight into a nested struct with <code class="language-plaintext highlighter-rouge">flatten</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Redis</span> <span class="n">RedisConfig</span> <span class="s">`source:"aws-sm://prod/redis" flatten:"json"`</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">RedisConfig</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Addr</span>     <span class="kt">string</span>        <span class="s">`mapstructure:"addr"`</span>
	<span class="n">Password</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`mapstructure:"password"`</span>
	<span class="n">DB</span>       <span class="kt">int</span>           <span class="s">`mapstructure:"db"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One AWS Secrets Manager call, one JSON blob, cleanly typed — with the password still redacting itself.</p>

<h2 id="writing-your-own-provider">Writing your own provider</h2>

<p>If your source of truth is homegrown — an internal config service, a weird legacy database table — you can teach mamori about it. The interface is intentionally tiny:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Provider</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">Scheme</span><span class="p">()</span> <span class="kt">string</span>
	<span class="n">Resolve</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">ref</span> <span class="n">Ref</span><span class="p">)</span> <span class="p">(</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A minimal implementation looks like this:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">Provider</span><span class="p">)</span> <span class="n">Resolve</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">ref</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Ref</span><span class="p">)</span> <span class="p">(</span><span class="n">mamori</span><span class="o">.</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
	<span class="n">raw</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">ref</span><span class="o">.</span><span class="n">Path</span><span class="p">)</span>
	<span class="k">if</span> <span class="n">isNotFound</span><span class="p">(</span><span class="n">err</span><span class="p">)</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Value</span><span class="p">{},</span> <span class="n">mamori</span><span class="o">.</span><span class="n">ErrNotFound</span> <span class="c">// MUST satisfy errors.Is</span>
	<span class="p">}</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="k">return</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Value</span><span class="p">{},</span> <span class="n">err</span>
	<span class="p">}</span>
	<span class="k">if</span> <span class="n">ref</span><span class="o">.</span><span class="n">Key</span> <span class="o">!=</span> <span class="s">""</span> <span class="p">{</span>
		<span class="n">raw</span><span class="p">,</span> <span class="n">err</span> <span class="o">=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">SelectKey</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="n">ref</span><span class="o">.</span><span class="n">Key</span><span class="p">)</span> <span class="c">// implements #key against a JSON payload</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
			<span class="k">return</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Value</span><span class="p">{},</span> <span class="n">err</span>
		<span class="p">}</span>
	<span class="p">}</span>
	<span class="k">return</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Value</span><span class="p">{</span>
		<span class="n">Bytes</span><span class="o">:</span>     <span class="n">raw</span><span class="p">,</span>
		<span class="n">Version</span><span class="o">:</span>   <span class="n">mamori</span><span class="o">.</span><span class="n">VersionHash</span><span class="p">(</span><span class="n">raw</span><span class="p">),</span> <span class="c">// change detection; use a native revision if you have one</span>
		<span class="n">Sensitive</span><span class="o">:</span> <span class="no">true</span><span class="p">,</span>                    <span class="c">// true for secret-bearing sources</span>
	<span class="p">},</span> <span class="no">nil</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">init</span><span class="p">()</span> <span class="p">{</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Register</span><span class="p">(</span><span class="n">New</span><span class="p">())</span> <span class="p">}</span> <span class="c">// database/sql pattern; panics on a duplicate scheme</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Version</code> field is what powers cheap change detection: if your backend exposes a revision (a <code class="language-plaintext highlighter-rouge">VersionId</code>, a Vault version, an mtime hash), return it; otherwise <code class="language-plaintext highlighter-rouge">mamori.VersionHash(bytes)</code> gives you a stable FNV hash for free. Two optional interfaces let you do better when the backend supports it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">WatchableProvider</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">Provider</span>
	<span class="n">Watch</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">ref</span> <span class="n">Ref</span><span class="p">)</span> <span class="p">(</span><span class="o">&lt;-</span><span class="k">chan</span> <span class="n">Update</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">type</span> <span class="n">BatchProvider</span> <span class="k">interface</span> <span class="p">{</span>
	<span class="n">Provider</span>
	<span class="n">ResolveBatch</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">refs</span> <span class="p">[]</span><span class="n">Ref</span><span class="p">)</span> <span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Implement <code class="language-plaintext highlighter-rouge">Watch</code> <strong>only</strong> if your backend can genuinely push — otherwise let mamori’s poller do the work, so behavior stays consistent across providers. Implement <code class="language-plaintext highlighter-rouge">ResolveBatch</code> if you can fetch many refs in one round trip (the AWS provider does this automatically).</p>

<p>Best of all, you don’t have to trust yourself to get the contract right. There’s a conformance kit — <code class="language-plaintext highlighter-rouge">github.com/xavidop/mamori/providertest</code> — that exercises resolution, not-found typing, version monotonicity, concurrency, context cancellation, native watching, goroutine hygiene, and even asserts you never accidentally log the payload. Point it at your provider and it tells you if you’ve broken the rules.</p>

<h2 id="middleware-cache-failover-audit-rate-limit">Middleware: cache, failover, audit, rate-limit</h2>

<p>Because every provider is just a <code class="language-plaintext highlighter-rouge">Provider</code>, you can wrap one in another. The <code class="language-plaintext highlighter-rouge">middleware</code> package ships the decorators you’d otherwise reinvent:</p>

<table>
  <thead>
    <tr>
      <th>Middleware</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Cache(ttl, inner)</code></td>
      <td>Memoize successful resolves. Errors and not-found are <strong>not</strong> cached.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Failover(primary, replicas...)</code></td>
      <td>Try the primary, fall back to replicas on transport errors. Not-found is authoritative.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RateLimit(rps, inner)</code></td>
      <td>Cap resolves per second so you don’t trip your secrets backend’s throttle.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Audit(logger, inner)</code></td>
      <td>Log scheme, ref, latency, and outcome — never the value.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Prefix(prefix, inner)</code></td>
      <td>Namespace every ref path — handy for multi-tenant setups.</td>
    </tr>
  </tbody>
</table>

<p>They compose by nesting, so you can read the stack top-down:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="s">"github.com/xavidop/mamori/middleware"</span>

<span class="n">mamori</span><span class="o">.</span><span class="n">WithProvider</span><span class="p">(</span>
	<span class="n">middleware</span><span class="o">.</span><span class="n">Cache</span><span class="p">(</span><span class="m">5</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Minute</span><span class="p">,</span>
		<span class="n">middleware</span><span class="o">.</span><span class="n">Audit</span><span class="p">(</span><span class="n">logger</span><span class="p">,</span>
			<span class="n">middleware</span><span class="o">.</span><span class="n">RateLimit</span><span class="p">(</span><span class="m">10</span><span class="p">,</span>
				<span class="n">middleware</span><span class="o">.</span><span class="n">Failover</span><span class="p">(</span><span class="n">primarySM</span><span class="p">,</span> <span class="n">replicaSM</span><span class="p">),</span>
			<span class="p">),</span>
		<span class="p">),</span>
	<span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Crucially, the shipped middleware preserves the <code class="language-plaintext highlighter-rouge">WatchableProvider</code> and <code class="language-plaintext highlighter-rouge">BatchProvider</code> capabilities of whatever they wrap — decorating a provider never silently downgrades it from push to poll.</p>

<h2 id="observability-out-of-the-box">Observability out of the box</h2>

<p>The core takes no OpenTelemetry dependency (keeping it lean), but there’s a bridge module when you want metrics and traces:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori/x/otel
</code></pre></div></div>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">(</span>
	<span class="s">"go.opentelemetry.io/otel"</span>
	<span class="n">mamoriotel</span> <span class="s">"github.com/xavidop/mamori/x/otel"</span>
	<span class="s">"github.com/xavidop/mamori"</span>
<span class="p">)</span>

<span class="n">meter</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">mamoriotel</span><span class="o">.</span><span class="n">NewMeter</span><span class="p">(</span><span class="n">otel</span><span class="o">.</span><span class="n">Meter</span><span class="p">(</span><span class="s">"mamori"</span><span class="p">))</span>
<span class="n">tracer</span> <span class="o">:=</span> <span class="n">mamoriotel</span><span class="o">.</span><span class="n">NewTracer</span><span class="p">(</span><span class="n">otel</span><span class="o">.</span><span class="n">Tracer</span><span class="p">(</span><span class="s">"mamori"</span><span class="p">))</span>

<span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithMeter</span><span class="p">(</span><span class="n">meter</span><span class="p">),</span>
	<span class="n">mamori</span><span class="o">.</span><span class="n">WithTracer</span><span class="p">(</span><span class="n">tracer</span><span class="p">),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>You get a <code class="language-plaintext highlighter-rouge">mamori.resolve.duration</code> histogram, <code class="language-plaintext highlighter-rouge">mamori.refresh.count</code> and <code class="language-plaintext highlighter-rouge">mamori.watch.errors</code> counters (all tagged by <code class="language-plaintext highlighter-rouge">scheme</code>), and a span per resolve. And because <code class="language-plaintext highlighter-rouge">WithMeter</code>/<code class="language-plaintext highlighter-rouge">WithTracer</code> take tiny internal interfaces, you can implement them against Prometheus, statsd, or a test recorder without pulling OTel into your binary at all.</p>

<h2 id="a-complete-runnable-example">A complete, runnable example</h2>

<p>Here is the whole loop — load, watch, react — in one file you can run today (it’s the <code class="language-plaintext highlighter-rouge">examples/basic</code> program from the repo):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">main</span>

<span class="k">import</span> <span class="p">(</span>
	<span class="s">"context"</span>
	<span class="s">"fmt"</span>
	<span class="s">"log/slog"</span>
	<span class="s">"os"</span>
	<span class="s">"time"</span>

	<span class="s">"github.com/xavidop/mamori"</span>
	<span class="s">"github.com/xavidop/mamori/secret"</span>
<span class="p">)</span>

<span class="k">const</span> <span class="n">tokenPath</span> <span class="o">=</span> <span class="s">"/tmp/mamori-example-token"</span>

<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">LogLevel</span> <span class="kt">string</span>        <span class="s">`source:"env:LOG_LEVEL" default:"info" validate:"oneof=debug info warn error"`</span>
	<span class="n">Workers</span>  <span class="kt">int</span>           <span class="s">`source:"env:WORKERS" default:"4" validate:"gte=1,lte=256"`</span>
	<span class="n">APIToken</span> <span class="n">secret</span><span class="o">.</span><span class="n">String</span> <span class="s">`source:"file:///tmp/mamori-example-token"`</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">main</span><span class="p">()</span> <span class="p">{</span>
	<span class="n">logger</span> <span class="o">:=</span> <span class="n">slog</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">slog</span><span class="o">.</span><span class="n">NewTextHandler</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Stdout</span><span class="p">,</span> <span class="no">nil</span><span class="p">))</span>
	<span class="n">_</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">WriteFile</span><span class="p">(</span><span class="n">tokenPath</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"initial-token"</span><span class="p">),</span> <span class="m">0</span><span class="n">o600</span><span class="p">)</span>
	<span class="k">defer</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span> <span class="n">_</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">Remove</span><span class="p">(</span><span class="n">tokenPath</span><span class="p">)</span> <span class="p">}()</span>

	<span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithCancel</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">())</span>
	<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

	<span class="n">w</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Watch</span><span class="p">[</span><span class="n">Config</span><span class="p">](</span><span class="n">ctx</span><span class="p">,</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">WithPollInterval</span><span class="p">(</span><span class="m">2</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnChange</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">ev</span> <span class="n">mamori</span><span class="o">.</span><span class="n">Change</span><span class="p">[</span><span class="n">Config</span><span class="p">])</span> <span class="p">{</span>
			<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">f</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">ev</span><span class="o">.</span><span class="n">Fields</span> <span class="p">{</span>
				<span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"config changed"</span><span class="p">,</span> <span class="s">"field"</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">Path</span><span class="p">,</span> <span class="s">"from"</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">OldVersion</span><span class="p">,</span> <span class="s">"to"</span><span class="p">,</span> <span class="n">f</span><span class="o">.</span><span class="n">NewVersion</span><span class="p">)</span>
			<span class="p">}</span>
			<span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">Changed</span><span class="p">(</span><span class="s">"APIToken"</span><span class="p">)</span> <span class="p">{</span>
				<span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"rotating clients with the new API token"</span><span class="p">,</span> <span class="s">"token"</span><span class="p">,</span> <span class="n">ev</span><span class="o">.</span><span class="n">New</span><span class="o">.</span><span class="n">APIToken</span><span class="p">)</span>
			<span class="p">}</span>
		<span class="p">}),</span>
		<span class="n">mamori</span><span class="o">.</span><span class="n">OnError</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span> <span class="n">logger</span><span class="o">.</span><span class="n">Warn</span><span class="p">(</span><span class="s">"reconcile error"</span><span class="p">,</span> <span class="s">"err"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span> <span class="p">}),</span>
	<span class="p">)</span>
	<span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
		<span class="n">logger</span><span class="o">.</span><span class="n">Error</span><span class="p">(</span><span class="s">"watch failed"</span><span class="p">,</span> <span class="s">"err"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
		<span class="n">os</span><span class="o">.</span><span class="n">Exit</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
	<span class="p">}</span>
	<span class="k">defer</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span> <span class="n">_</span> <span class="o">=</span> <span class="n">w</span><span class="o">.</span><span class="n">Close</span><span class="p">()</span> <span class="p">}()</span>

	<span class="n">cfg</span> <span class="o">:=</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span>
	<span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"loaded config"</span><span class="p">,</span> <span class="s">"logLevel"</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">LogLevel</span><span class="p">,</span> <span class="s">"workers"</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Workers</span><span class="p">,</span> <span class="s">"apiToken"</span><span class="p">,</span> <span class="n">cfg</span><span class="o">.</span><span class="n">APIToken</span><span class="p">)</span>

	<span class="c">// Simulate a rotation a few seconds in.</span>
	<span class="k">go</span> <span class="k">func</span><span class="p">()</span> <span class="p">{</span>
		<span class="n">time</span><span class="o">.</span><span class="n">Sleep</span><span class="p">(</span><span class="m">3</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
		<span class="n">_</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">WriteFile</span><span class="p">(</span><span class="n">tokenPath</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"rotated-token"</span><span class="p">),</span> <span class="m">0</span><span class="n">o600</span><span class="p">)</span>
	<span class="p">}()</span>

	<span class="n">time</span><span class="o">.</span><span class="n">Sleep</span><span class="p">(</span><span class="m">8</span> <span class="o">*</span> <span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
	<span class="n">logger</span><span class="o">.</span><span class="n">Info</span><span class="p">(</span><span class="s">"final config"</span><span class="p">,</span> <span class="s">"apiToken"</span><span class="p">,</span> <span class="n">w</span><span class="o">.</span><span class="n">Get</span><span class="p">()</span><span class="o">.</span><span class="n">APIToken</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">LOG_LEVEL</span><span class="o">=</span>debug <span class="nv">WORKERS</span><span class="o">=</span>8 go run ./examples/basic
</code></pre></div></div>

<p>Notice the <code class="language-plaintext highlighter-rouge">token</code> field in the log output: it prints <code class="language-plaintext highlighter-rouge">[REDACTED]</code> every time, even though the program is actively rotating it. That’s the whole philosophy in one demo — live rotation, no restart, and nothing leaks.</p>

<h2 id="when-to-reach-for-mamori-and-when-not">When to reach for mamori (and when not)</h2>

<p><strong>Reach for it when:</strong></p>

<ul>
  <li>Your config or secrets genuinely change while the process runs, and you want to react in memory instead of bouncing pods.</li>
  <li>You pull from more than one source and are tired of a different SDK and refresh strategy for each.</li>
  <li>Secret hygiene matters and you want redaction and a <code class="language-plaintext highlighter-rouge">go vet</code> guardrail for free.</li>
  <li>You run outside Kubernetes, or you’re on Kubernetes but want to skip the <code class="language-plaintext highlighter-rouge">Secret</code>-sync hop.</li>
</ul>

<p><strong>Probably skip it when:</strong></p>

<ul>
  <li>Your config is a log level and a port from the environment, read once at boot. <code class="language-plaintext highlighter-rouge">os.Getenv</code> is fine — don’t add a dependency.</li>
  <li>You need a secrets <em>store</em> (encryption at rest, a server component) — that’s Vault’s job, not mamori’s.</li>
  <li>You’re syncing secrets <em>between</em> stores — that’s what ESO, <code class="language-plaintext highlighter-rouge">vals</code>, and <code class="language-plaintext highlighter-rouge">teller</code> are for.</li>
</ul>

<h2 id="wrapping-up">Wrapping up</h2>

<p>mamori is the library I wish I’d had every time I typed <code class="language-plaintext highlighter-rouge">type ConfigManager struct</code> for the fifth time. It takes the boring, error-prone, security-sensitive plumbing — load, validate, watch, redact, reconcile — and makes it a typed struct and one function call.</p>

<p>It’s <strong>open source under the MIT license</strong> and currently at <code class="language-plaintext highlighter-rouge">v0.1.0</code> — early, pre-1.0, and moving fast, so feedback and issues are genuinely welcome. Give it a spin:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>go get github.com/xavidop/mamori
</code></pre></div></div>

<ul>
  <li><strong>Docs:</strong> <a href="https://mamorigo.dev/docs">mamorigo.dev/docs</a></li>
  <li><strong>Homepage:</strong> <a href="https://mamorigo.dev">mamorigo.dev</a></li>
  <li><strong>Source:</strong> <a href="https://github.com/xavidop/mamori">github.com/xavidop/mamori</a></li>
</ul>

<h3 id="provider-deep-dives">Provider deep-dives</h3>

<p>If you live on a particular platform, I’ve written focused, hands-on companions:</p>

<ul>
  <li><a href="/kubernetes/2026-07-22-mamori-hot-reload-kubernetes-secrets-configmaps-go/">Hot-reloading Kubernetes Secrets and ConfigMaps in Go — no pod restarts</a></li>
  <li><a href="/aws/2026-07-22-mamori-rotate-aws-secrets-manager-go/">Rotating AWS Secrets Manager secrets without restarting your Go service</a></li>
  <li><a href="/gcp/2026-07-22-mamori-gcp-secret-manager-live-reload-go/">GCP Secret Manager, live-reloaded in Go</a></li>
  <li><a href="/azure/2026-07-22-mamori-azure-key-vault-go/">Azure Key Vault secrets that reconcile themselves in Go</a></li>
</ul>

<p>If you build something with it, or write a provider for a backend I haven’t covered yet, I’d love to hear about it. Happy coding!</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="go" /><category term="go" /><category term="golang" /><category term="mamori" /><category term="secrets" /><category term="configuration" /><category term="reconciliation" /><category term="kubernetes" /><summary type="html"><![CDATA[Every Go service ends up hand-rolling the same thing: a config loader, a secrets fetcher, a ticker to refresh them, a mutex, and a prayer that nothing leaks into the logs. mamori is a new open-source Go library that loads configuration and secrets from more than 30 sources into typed, validated structs, and keeps them reconciled at runtime, without a restart.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/mamori.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/mamori.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Durable Genkit Flows with Temporal: Introducing the genkitx-temporal Plugin (English)</title><link href="https://xavidop.me/genkit/2026-05-16-genkitx-temporal-durable-genkit-flows/" rel="alternate" type="text/html" title="Durable Genkit Flows with Temporal: Introducing the genkitx-temporal Plugin (English)" /><published>2026-05-16T00:00:00+00:00</published><updated>2026-05-17T16:50:50+00:00</updated><id>https://xavidop.me/genkit/genkitx-temporal-durable-genkit-flows</id><content type="html" xml:base="https://xavidop.me/genkit/2026-05-16-genkitx-temporal-durable-genkit-flows/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#what-is-genkitx-temporal" id="markdown-toc-what-is-genkitx-temporal">What is <code class="language-plaintext highlighter-rouge">genkitx-temporal</code>?</a></li>
  <li><a href="#why-temporal-and-genkit-are-a-great-match" id="markdown-toc-why-temporal-and-genkit-are-a-great-match">Why Temporal and Genkit are a great match</a></li>
  <li><a href="#installation" id="markdown-toc-installation">Installation</a></li>
  <li><a href="#usage-end-to-end" id="markdown-toc-usage-end-to-end">Usage end to end</a>    <ol>
      <li><a href="#1-define-a-flow-with-definetemporalflow" id="markdown-toc-1-define-a-flow-with-definetemporalflow">1. Define a flow with <code class="language-plaintext highlighter-rouge">defineTemporalFlow</code></a></li>
      <li><a href="#2-start-a-worker" id="markdown-toc-2-start-a-worker">2. Start a Worker</a></li>
      <li><a href="#3-execute-a-flow-as-a-temporal-workflow" id="markdown-toc-3-execute-a-flow-as-a-temporal-workflow">3. Execute a flow as a Temporal Workflow</a></li>
      <li><a href="#configuration" id="markdown-toc-configuration">Configuration</a></li>
      <li><a href="#advanced-combine-with-your-own-workflows-and-activities" id="markdown-toc-advanced-combine-with-your-own-workflows-and-activities">Advanced: combine with your own workflows and activities</a></li>
    </ol>
  </li>
  <li><a href="#api-summary" id="markdown-toc-api-summary">API summary</a></li>
  <li><a href="#when-to-reach-for-genkitx-temporal" id="markdown-toc-when-to-reach-for-genkitx-temporal">When to reach for <code class="language-plaintext highlighter-rouge">genkitx-temporal</code></a></li>
  <li><a href="#how-it-pairs-with-the-rest-of-the-genkit-ecosystem" id="markdown-toc-how-it-pairs-with-the-rest-of-the-genkit-ecosystem">How it pairs with the rest of the Genkit ecosystem</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>Anyone who has shipped a non-trivial Gen AI feature to production has hit the same wall: the happy path is fun to demo, but the unhappy path is brutal. Model providers throw 5xx errors. Rate limits kick in at the worst possible moment. A long-running agent gets halfway through a 12-step plan and the pod is restarted by Kubernetes. A user closes the tab in the middle of a streaming response and you have no idea what state the flow ended up in.</p>

<p><a href="https://genkit.dev">Genkit</a> gives you a clean way to author those LLM-orchestrating pipelines as <strong>flows</strong>, but a Genkit flow, by itself, is just a function. It lives and dies with the process that invokes it. There is no durable history, no automatic retry policy, no built-in cancellation, no operations UI.</p>

<p>This is exactly the gap that <a href="https://temporal.io">Temporal</a> was designed to close for general-purpose backend code, and it is the gap that the new <a href="https://github.com/xavidop/genkitx-temporal"><strong>genkitx-temporal</strong></a> plugin closes for Genkit flows.</p>

<p>In this article we will look at:</p>

<ul>
  <li>What the plugin actually does.</li>
  <li>Why Temporal and Genkit are a particularly good match.</li>
  <li>How to use it end to end: define, register, run.</li>
  <li>When you should reach for it (and when you probably shouldn’t).</li>
</ul>

<h2 id="what-is-genkitx-temporal">What is <code class="language-plaintext highlighter-rouge">genkitx-temporal</code>?</h2>

<p><code class="language-plaintext highlighter-rouge">genkitx-temporal</code> is a Genkit plugin that lets you <strong>execute any Genkit flow inside a Temporal Workflow</strong>, transparently. You keep writing flows the way you always have; the plugin takes care of:</p>

<ul>
  <li>Registering the flow so a Temporal Worker can pick it up.</li>
  <li>Wrapping each execution in a deterministic Temporal Workflow.</li>
  <li>Running the non-deterministic LLM/tool/RAG work inside a Temporal Activity, so retries and timeouts are safe.</li>
  <li>Giving you helpers to start workflows from any client.</li>
</ul>

<p>Under the hood, the plugin ships a generic, deterministic workflow called <code class="language-plaintext highlighter-rouge">runGenkitFlow</code> that invokes a single Temporal Activity called <code class="language-plaintext highlighter-rouge">runGenkitFlowActivity</code>. The activity looks up your Genkit flow by name in an in-process registry and runs it inside a full Node environment, where all the messy, non-deterministic things (network calls, model calls, tool calls, RAG lookups) are perfectly fine.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────┐  start workflow   ┌──────────────────────┐
│  Client    │ ────────────────▶ │  Temporal Server     │
└────────────┘                   └─────────┬────────────┘
                                           │ task
                                           ▼
                                ┌──────────────────────┐
                                │  Worker process      │
                                │  ┌────────────────┐  │
                                │  │ runGenkitFlow  │  │  (workflow, sandboxed)
                                │  │       │        │  │
                                │  │       ▼        │  │
                                │  │ runGenkit-     │  │  (activity, full Node)
                                │  │ FlowActivity   │  │
                                │  │       │        │  │
                                │  │       ▼        │  │
                                │  │  your Genkit   │  │
                                │  │  flow (LLM…)   │  │
                                │  └────────────────┘  │
                                └──────────────────────┘
</code></pre></div></div>

<p>This split is the whole trick. Temporal Workflows are required to be deterministic so that they can be <strong>replayed</strong> from event history after a crash, deploy or scale event. LLM calls obviously are not deterministic. Putting the LLM work inside an Activity is the canonical Temporal pattern, and the plugin does it for you so you don’t have to think about it.</p>

<h2 id="why-temporal-and-genkit-are-a-great-match">Why Temporal and Genkit are a great match</h2>

<p>It is easy to underestimate how much engineering is hiding behind “just call the model again if it fails”. Once you start building real agents, the list of things you want from your runtime grows quickly. Genkit gives you the authoring primitives, and Temporal gives you the runtime guarantees. Together:</p>

<ul>
  <li><strong>Automatic retries for transient errors.</strong> LLM providers regularly return 429s and 5xx. Tool calls hit the network. Vector stores time out. Temporal lets you express retry policies (exponential backoff, max attempts, non-retryable error types) declaratively, applied to every flow execution.</li>
  <li><strong>Durable history.</strong> Every event in your flow’s execution is persisted by the Temporal Server. If your Worker pod is killed mid-flow, another Worker picks up the workflow exactly where it left off, with all prior activity results intact. No partial state, no double-charging the user, no orphan jobs.</li>
  <li><strong>Timeouts, heartbeats and cancellation.</strong> Long-running agents that browse, plan and call tools for minutes are a nightmare to control with plain HTTP. Temporal models start-to-close, schedule-to-close and heartbeat timeouts as first-class concepts, plus explicit cancellation semantics you can wire to a user closing a tab.</li>
  <li><strong>Operational visibility out of the box.</strong> The Temporal UI gives you a per-execution timeline of every workflow and activity. You can see inputs, outputs, retries, failures and stack traces, search by workflow id, terminate or signal running workflows. This complements the <a href="/genkit/2026-05-14-dev-ui-shift-left-genkit-vercel-mastra/">Genkit Developer UI</a> nicely: Genkit’s UI is your <strong>local debugging tool</strong> during development; Temporal’s UI is your <strong>operational dashboard</strong> in staging and production.</li>
  <li><strong>Horizontal scalability for free.</strong> Need more throughput? Run more Worker processes pointing at the same task queue. The Temporal Server load-balances workflows and activities across them. Your flows did not need to change.</li>
  <li><strong>Long-running, human-in-the-loop friendly.</strong> Temporal workflows can sleep for days, wait for signals from external systems, and resume cleanly. Perfect for agents that need approval before executing a sensitive tool, or RAG pipelines that wait for a document indexing job to finish.</li>
</ul>

<p>In other words, <code class="language-plaintext highlighter-rouge">genkitx-temporal</code> turns your Genkit flows from “smart functions inside a Node process” into <strong>first-class durable workloads</strong> without changing the way you write them.</p>

<h2 id="installation">Installation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install </span>genkitx-temporal genkit
</code></pre></div></div>

<p>The Temporal SDK packages are peer-installed automatically as dependencies of the plugin.</p>

<p>You also need a running Temporal Server. For local development, the easiest path is:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>temporal
temporal server start-dev
</code></pre></div></div>

<p>This starts a Temporal Server on <code class="language-plaintext highlighter-rouge">localhost:7233</code> and the UI on <code class="language-plaintext highlighter-rouge">http://localhost:8233</code>.</p>

<h2 id="usage-end-to-end">Usage end to end</h2>

<p>The plugin exposes a small, focused API. Three things are enough to ship a durable Genkit flow: define it with the Temporal-aware helper, start a Worker, and execute it from a client.</p>

<h3 id="1-define-a-flow-with-definetemporalflow">1. Define a flow with <code class="language-plaintext highlighter-rouge">defineTemporalFlow</code></h3>

<p><code class="language-plaintext highlighter-rouge">defineTemporalFlow</code> is a drop-in replacement for <code class="language-plaintext highlighter-rouge">ai.defineFlow</code>. The returned object is a normal Genkit flow, so you can still call it directly, expose it via the Developer UI, run it from tests, etc. The only difference is that it is also <strong>registered</strong> internally so a Temporal Worker can find it by name.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// flows.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">genkit</span><span class="p">,</span> <span class="nx">z</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkit</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">googleAI</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/google-genai</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">defineTemporalFlow</span><span class="p">,</span> <span class="nx">temporal</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkitx-temporal</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">ai</span> <span class="o">=</span> <span class="nf">genkit</span><span class="p">({</span>
  <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">googleAI</span><span class="p">(),</span>
    <span class="nf">temporal</span><span class="p">({</span> <span class="na">taskQueue</span><span class="p">:</span> <span class="dl">'</span><span class="s1">my-queue</span><span class="dl">'</span> <span class="p">}),</span>
  <span class="p">],</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">),</span>
<span class="p">});</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">jokeFlow</span> <span class="o">=</span> <span class="nf">defineTemporalFlow</span><span class="p">(</span>
  <span class="nx">ai</span><span class="p">,</span>
  <span class="p">{</span>
    <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">jokeFlow</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">inputSchema</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">(),</span>
    <span class="na">outputSchema</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">(),</span>
  <span class="p">},</span>
  <span class="k">async </span><span class="p">(</span><span class="nx">subject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">text</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">(</span><span class="s2">`Tell me a joke about </span><span class="p">${</span><span class="nx">subject</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">text</span><span class="p">;</span>
  <span class="p">},</span>
<span class="p">);</span>
</code></pre></div></div>

<p>A few things to notice:</p>

<ul>
  <li>You configure the plugin like any other Genkit plugin, passing the Temporal task queue (and optionally <code class="language-plaintext highlighter-rouge">address</code>, <code class="language-plaintext highlighter-rouge">namespace</code>, etc.).</li>
  <li>The flow body is <strong>plain Genkit code</strong>. No Temporal-specific imports, no <code class="language-plaintext highlighter-rouge">proxyActivities</code>, no determinism gymnastics. The plugin handles all that for you.</li>
  <li>The flow’s <code class="language-plaintext highlighter-rouge">name</code> is also its Temporal registration key. Keep it unique within your Worker.</li>
</ul>

<h3 id="2-start-a-worker">2. Start a Worker</h3>

<p>Workers are the processes that actually execute your flows. A Worker imports your flows (so the registry is populated) and then calls <code class="language-plaintext highlighter-rouge">startTemporalWorker</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// worker.ts</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">./flows</span><span class="dl">'</span><span class="p">;</span>   <span class="c1">// side-effect import: registers the flows</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">startTemporalWorker</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkitx-temporal</span><span class="dl">'</span><span class="p">;</span>

<span class="nf">startTemporalWorker</span><span class="p">({</span> <span class="na">taskQueue</span><span class="p">:</span> <span class="dl">'</span><span class="s1">my-queue</span><span class="dl">'</span> <span class="p">})</span>
  <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="nx">e</span><span class="p">);</span> <span class="nx">process</span><span class="p">.</span><span class="nf">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span> <span class="p">});</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node ./dist/worker.js
</code></pre></div></div>

<p>You can run as many Worker processes as you want against the same task queue. Temporal will distribute work across them automatically. If a Worker dies mid-flow, another one picks up.</p>

<h3 id="3-execute-a-flow-as-a-temporal-workflow">3. Execute a flow as a Temporal Workflow</h3>

<p>From any client (an HTTP handler, a CLI, a cron job, another workflow), use <code class="language-plaintext highlighter-rouge">executeTemporalFlow</code> to start a workflow and <code class="language-plaintext highlighter-rouge">await</code> its result:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// client.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">executeTemporalFlow</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkitx-temporal</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">jokeFlow</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./flows</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">executeTemporalFlow</span><span class="p">(</span><span class="nx">jokeFlow</span><span class="p">,</span> <span class="dl">'</span><span class="s1">cats</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">taskQueue</span><span class="p">:</span> <span class="dl">'</span><span class="s1">my-queue</span><span class="dl">'</span><span class="p">,</span>
<span class="p">});</span>
<span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="nx">result</span><span class="p">);</span>
</code></pre></div></div>

<p>If you don’t want to block on the result, use <code class="language-plaintext highlighter-rouge">startTemporalFlow</code> instead. It returns the raw Temporal <code class="language-plaintext highlighter-rouge">WorkflowHandle</code>, which lets you query, signal, or cancel the running workflow later. This is the building block for human-in-the-loop scenarios, scheduled flows, fan-out/fan-in patterns, and so on.</p>

<h3 id="configuration">Configuration</h3>

<p><code class="language-plaintext highlighter-rouge">temporal(options)</code> and every helper accept the same connection options. Anything you don’t pass falls back to environment variables, then to sensible defaults:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Env var</th>
      <th>Default</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">address</code></td>
      <td><code class="language-plaintext highlighter-rouge">TEMPORAL_ADDRESS</code></td>
      <td><code class="language-plaintext highlighter-rouge">localhost:7233</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">namespace</code></td>
      <td><code class="language-plaintext highlighter-rouge">TEMPORAL_NAMESPACE</code></td>
      <td><code class="language-plaintext highlighter-rouge">default</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">taskQueue</code></td>
      <td><code class="language-plaintext highlighter-rouge">TEMPORAL_TASK_QUEUE</code></td>
      <td><code class="language-plaintext highlighter-rouge">genkit</code></td>
    </tr>
  </tbody>
</table>

<p>This makes it straightforward to run the same code locally against a dev server and in production against Temporal Cloud or a self-hosted cluster.</p>

<h3 id="advanced-combine-with-your-own-workflows-and-activities">Advanced: combine with your own workflows and activities</h3>

<p>The bundled <code class="language-plaintext highlighter-rouge">runGenkitFlow</code> workflow is enough for the common case. If you want to mix Genkit flows with your own existing Temporal workflows and activities, the plugin lets you bring your own:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nf">startTemporalWorker</span><span class="p">({</span>
  <span class="na">taskQueue</span><span class="p">:</span> <span class="dl">'</span><span class="s1">my-queue</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">workflowsPath</span><span class="p">:</span> <span class="nx">require</span><span class="p">.</span><span class="nf">resolve</span><span class="p">(</span><span class="dl">'</span><span class="s1">./my-workflows</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">activities</span><span class="p">:</span> <span class="p">{</span> <span class="p">...</span><span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./my-activities</span><span class="dl">'</span><span class="p">)</span> <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my-activities.ts</span>
<span class="k">export</span> <span class="p">{</span> <span class="nx">runGenkitFlowActivity</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkitx-temporal/activities</span><span class="dl">'</span><span class="p">;</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">myOtherActivity</span><span class="p">(</span><span class="cm">/* ... */</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
</code></pre></div></div>

<p>Re-exporting <code class="language-plaintext highlighter-rouge">runGenkitFlowActivity</code> keeps the built-in workflow working, so you can compose Genkit flows alongside hand-written activities for the parts of your system that aren’t AI.</p>

<h2 id="api-summary">API summary</h2>

<p>The full public surface is tiny:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">temporal(options?)</code> — the Genkit plugin.</li>
  <li><code class="language-plaintext highlighter-rouge">defineTemporalFlow(ai, config, fn)</code> — define a flow and register it for Temporal execution.</li>
  <li><code class="language-plaintext highlighter-rouge">startTemporalWorker(options?)</code> — start a Worker process.</li>
  <li><code class="language-plaintext highlighter-rouge">executeTemporalFlow(flow, input, options?)</code> — run a flow inside a Workflow and await the result.</li>
  <li><code class="language-plaintext highlighter-rouge">startTemporalFlow(flow, input, options?)</code> — same, but returns the <code class="language-plaintext highlighter-rouge">WorkflowHandle</code> for fire-and-forget / signalling.</li>
  <li><code class="language-plaintext highlighter-rouge">runGenkitFlowActivity</code> — the underlying activity, re-exported so you can combine it with your own activities.</li>
  <li><code class="language-plaintext highlighter-rouge">registerTemporalFlow(name, flow)</code> — manually register a flow that was defined elsewhere (useful when wrapping flows you don’t own).</li>
</ul>

<p>A small API surface is the point. The plugin is intentionally a thin bridge between two well-designed systems; it does not try to reinvent either of them.</p>

<h2 id="when-to-reach-for-genkitx-temporal">When to reach for <code class="language-plaintext highlighter-rouge">genkitx-temporal</code></h2>

<p>Not every flow needs a durable runtime. A streaming chat response that takes 800ms and either succeeds or is retried by the user is fine running on a plain HTTP handler.</p>

<p>You will feel the benefits the moment your flows look like one of these:</p>

<ul>
  <li><strong>Multi-step agents</strong> that orchestrate several model calls and tool invocations, where partial progress is expensive to throw away.</li>
  <li><strong>Long-running pipelines</strong> (document ingestion, batch summarization, fine-tuning prep) where individual steps can take minutes and the process must survive deploys.</li>
  <li><strong>Critical business workflows</strong> (refunds, account changes, contract generation) where you cannot afford to lose state or accidentally execute a step twice.</li>
  <li><strong>Human-in-the-loop agents</strong> that need to pause for approval, an external webhook, or a manual review before proceeding.</li>
  <li><strong>Anything you currently glue together with a job queue, a retry library, and a state machine.</strong> Temporal subsumes all three, and <code class="language-plaintext highlighter-rouge">genkitx-temporal</code> plugs Genkit straight into it.</li>
</ul>

<p>If your team already runs Temporal for non-AI workloads, this plugin is a no-brainer: it lets your Gen AI features inherit all the operational maturity your platform team has already built around it.</p>

<h2 id="how-it-pairs-with-the-rest-of-the-genkit-ecosystem">How it pairs with the rest of the Genkit ecosystem</h2>

<p>The thing I like the most about this plugin is that it composes cleanly with everything else Genkit gives you, not just one or two features:</p>

<ul>
  <li><strong>Genkit Developer UI.</strong> Because <code class="language-plaintext highlighter-rouge">defineTemporalFlow</code> returns a normal Genkit flow, you can still iterate on it locally with the <a href="/genkit/2026-05-14-dev-ui-shift-left-genkit-vercel-mastra/">Genkit Developer UI</a>: fast feedback loop during development, durable execution in production.</li>
  <li><strong>Genkit middleware.</strong> <a href="/genkit/2026-05-13-genkit-middleware/">Middleware</a> (in-call retries, fallbacks, skill injection, tool approval, prompt rewriting, etc.) runs <em>inside</em> the activity. You get two complementary layers of resilience: middleware for fine-grained in-call recovery, Temporal for whole-flow durability and replay.</li>
  <li><strong>Tools and function calling.</strong> Tools defined with <code class="language-plaintext highlighter-rouge">ai.defineTool</code> are just normal flow code from Temporal’s perspective. Their calls, retries and outputs appear in both the Genkit trace and the Temporal event history.</li>
  <li><strong>RAG primitives.</strong> Retrievers, indexers, embedders and rerankers all live inside the activity. That means heavy ingestion jobs (chunking, embedding, upserting into a vector store) inherit Temporal’s retry policies and survive restarts mid-batch.</li>
  <li><strong>Evaluators and datasets.</strong> Genkit’s evaluators are flows like any other, so you can run eval jobs as Temporal Workflows, schedule them, fan them out across Workers, and inspect every run in the Temporal UI.</li>
  <li><strong>Prompts and Dotprompt.</strong> Versioned <code class="language-plaintext highlighter-rouge">.prompt</code> files, prompt registries and structured outputs all work unchanged. The flow body is plain Genkit.</li>
  <li><strong>Plugins and model providers.</strong> Any Genkit plugin (Google AI, Vertex AI, OpenAI, Anthropic, Ollama, local models, vector stores, etc.) plugs in as usual; the Temporal layer doesn’t care which provider is on the other side of the call.</li>
  <li><strong>Telemetry and tracing.</strong> Genkit’s OpenTelemetry traces continue to be emitted from inside the activity, so they show up in whatever observability backend you already use, alongside Temporal’s own event history.</li>
  <li><strong>Deployment surfaces.</strong> Flows can still be exposed as HTTP endpoints, Cloud Functions, Firebase callable functions or Express handlers; <code class="language-plaintext highlighter-rouge">executeTemporalFlow</code> is just one more entry point, and you can mix and match (e.g. quick chat requests over HTTP, long agent runs through Temporal).</li>
  <li><strong>Multi-language story.</strong> Genkit is available in JS/TS, Go, Python (preview), Dart/Flutter (preview) and through a community Java SDK. This particular plugin targets JS/TS, but the architectural pattern — define your AI logic in Genkit, run it as a Temporal Workflow — is reusable across runtimes thanks to Temporal’s polyglot SDKs.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Genkit is a great way to <strong>author</strong> Gen AI logic. Temporal is a great way to <strong>run</strong> any long-running, failure-prone workload. <code class="language-plaintext highlighter-rouge">genkitx-temporal</code> is the missing adapter between the two: a small, focused plugin that turns every Genkit flow into a durable, retryable, observable Temporal Workflow without asking you to rewrite a single line of business logic.</p>

<p>If you are building anything more ambitious than a single-turn chat endpoint, give it a try:</p>

<ul>
  <li>Source: <a href="https://github.com/xavidop/genkitx-temporal">github.com/xavidop/genkitx-temporal</a></li>
  <li>Docs: <a href="https://xavidop.github.io/genkitx-temporal/">xavidop.github.io/genkitx-temporal</a></li>
  <li>Runnable example: <a href="https://github.com/xavidop/genkitx-temporal/tree/main/examples/test-app"><code class="language-plaintext highlighter-rouge">examples/test-app</code></a></li>
</ul>

<p>Your future on-call self will thank you the first time a model provider has a bad afternoon and your agents keep humming along regardless.</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="genkit" /><category term="genkit" /><category term="temporal" /><category term="genkitx" /><category term="durable-execution" /><category term="workflows" /><summary type="html"><![CDATA[Genkit flows are great at orchestrating LLMs, tools and RAG, but they live and die with the process that runs them. The new genkitx-temporal plugin lets you run any Genkit flow as a Temporal Workflow, giving you retries, durable history, timeouts, cancellation and a UI to inspect every execution.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/genkit-temporal.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/genkit-temporal.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Why a Local Debugging Tool is Non-Negotiable for Building AI Apps: Genkit Developer UI vs Vercel AI SDK DevTools vs Mastra Studio (English)</title><link href="https://xavidop.me/genkit/2026-05-14-debugging-tool-shift-left-genkit-vercel-mastra/" rel="alternate" type="text/html" title="Why a Local Debugging Tool is Non-Negotiable for Building AI Apps: Genkit Developer UI vs Vercel AI SDK DevTools vs Mastra Studio (English)" /><published>2026-05-14T00:00:00+00:00</published><updated>2026-05-16T09:41:39+00:00</updated><id>https://xavidop.me/genkit/debugging-tool-shift-left-genkit-vercel-mastra</id><content type="html" xml:base="https://xavidop.me/genkit/2026-05-14-debugging-tool-shift-left-genkit-vercel-mastra/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#shift-left-applied-to-gen-ai" id="markdown-toc-shift-left-applied-to-gen-ai">Shift-left, applied to Gen AI</a>    <ol>
      <li><a href="#local-vs-hosted" id="markdown-toc-local-vs-hosted">Local vs hosted</a></li>
      <li><a href="#what-good-looks-like" id="markdown-toc-what-good-looks-like">What “good” looks like</a></li>
    </ol>
  </li>
  <li><a href="#genkit-developer-ui" id="markdown-toc-genkit-developer-ui">Genkit Developer UI</a></li>
  <li><a href="#vercel-ai-sdk-devtools" id="markdown-toc-vercel-ai-sdk-devtools">Vercel AI SDK DevTools</a></li>
  <li><a href="#mastra-studio" id="markdown-toc-mastra-studio">Mastra Studio</a></li>
  <li><a href="#side-by-side-comparison" id="markdown-toc-side-by-side-comparison">Side-by-side comparison</a></li>
  <li><a href="#when-i-would-pick-which" id="markdown-toc-when-i-would-pick-which">When I would pick which</a></li>
  <li><a href="#the-moral-of-the-story-pick-a-debugging-tool-any-debugging-tool" id="markdown-toc-the-moral-of-the-story-pick-a-debugging-tool-any-debugging-tool">The moral of the story: pick a debugging tool, any debugging tool</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>If you have ever shipped a backend service without a debugger, a hot-reload dev server or decent logs, you remember how painful it was. Now imagine doing it in a world where your “function” is a non-deterministic black box that can rewrite its own output every time you call it. Welcome to building Gen AI applications.</p>

<p>The single biggest productivity multiplier I have found in the last two years of shipping AI apps is <strong>a good local debugging tool</strong>. Not a hosted dashboard. Not a cloud trace viewer with a 30-second propagation delay. A local tool that runs next to your code, picks up your edits, lets you replay a request with a tweaked prompt, and shows the full trace, tool call by tool call. We will get into the local-vs-hosted trade-off later in the article.</p>

<p>This article is about why that matters and how the three leading JS/TS Gen AI frameworks approach it:</p>

<ul>
  <li><a href="https://genkit.dev/docs/js/devtools/">Genkit Developer UI</a></li>
  <li><a href="https://ai-sdk.dev/docs/ai-sdk-core/devtools">Vercel AI SDK DevTools</a></li>
  <li><a href="https://mastra.ai/docs/studio/overview">Mastra Studio</a></li>
</ul>

<p>They are three very different products solving overlapping problems, and each one makes different trade-offs that are worth understanding before you commit to one.</p>

<h2 id="shift-left-applied-to-gen-ai">Shift-left, applied to Gen AI</h2>

<p>“Shift-left” comes from the world of testing and security: the earlier in the development lifecycle you catch a problem, the cheaper it is to fix. Bugs found in production cost orders of magnitude more than bugs found while you’re typing.</p>

<p>Gen AI apps make this principle existential, not just economical, because the failure modes are weirder:</p>

<ul>
  <li>A prompt regression doesn’t show up as a stack trace. It shows up as the wrong tone, a hallucinated fact or a tool call that misfires once every twenty runs.</li>
  <li>A new model version can quietly change behavior across thousands of code paths with no warning.</li>
  <li>A tool that returns slightly different JSON can cause silent downstream breakage.</li>
</ul>

<p>The only way to keep your sanity is to <strong>collapse the feedback loop to seconds</strong>. You want to be able to:</p>

<ol>
  <li>Change a prompt or a piece of orchestration code.</li>
  <li>Re-run that exact unit, with that exact input.</li>
  <li>See the model’s input, its output, every tool call, every retry, every token used.</li>
  <li>Compare it to the previous run.</li>
  <li>Decide if it’s better, worse or different.</li>
</ol>

<p>If any one of those steps requires deploying, redeploying, opening a cloud console or grep’ing logs, you have already lost. Cycle time is the metric. A debugging tool is what makes that cycle time low enough to iterate productively.</p>

<h3 id="local-vs-hosted">Local vs hosted</h3>

<p>It is worth being explicit about this trade-off, because all three tools in this article are primarily local:</p>

<ul>
  <li>A <strong>local</strong> debugging tool runs on your machine, next to your code, with zero network latency between your edits and what you see. Iteration is fast, traces are immediate, and there is no risk of leaking prompts/responses to a third party. The downside is that it’s just for you.</li>
  <li>A <strong>hosted</strong> observability platform (Langfuse, LangSmith, cloud-native APM tools, etc.) is shared by your team, persists data long-term and is essential for production monitoring. The trade-off is propagation delay, configuration overhead and, in some cases, data residency concerns.</li>
</ul>

<p>These are complementary, not competing. The argument here is that the <em>local</em> part of the loop is the one that’s still often missing, and that’s where shift-left lives.</p>

<h3 id="what-good-looks-like">What “good” looks like</h3>

<p>After working with all three of the tools below, I would summarize the qualities of a good Gen AI debugging tool as:</p>

<ul>
  <li><strong>Zero-config or near-zero-config</strong> — it should pick up your code, not the other way around.</li>
  <li><strong>Live reload</strong> — edit your code, save, hit “run” again. No restart.</li>
  <li><strong>Interactive runners</strong> — invoke any prompt, tool, model, or higher-level primitive (agent, workflow, etc.) directly with arbitrary input.</li>
  <li><strong>Full traces</strong> — every step of the generation loop, with input/output/usage at each node.</li>
  <li><strong>Replay and modify</strong> — re-run any past trace with a tweak.</li>
  <li><strong>Minimal code changes</strong> — the framework should be observable by default, ideally without you having to remember to wrap each model call.</li>
</ul>

<p>Let’s see how each tool stacks up.</p>

<h2 id="genkit-developer-ui">Genkit Developer UI</h2>

<p>The Genkit team treats the <a href="https://genkit.dev/docs/js/devtools/">Developer UI</a> as a first-class part of the framework. It ships with the <code class="language-plaintext highlighter-rouge">genkit-cli</code> package and <strong>requires no code changes</strong> in your application to attach to your running process.</p>

<p>You install the CLI once globally:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> genkit-cli
</code></pre></div></div>

<p>And then start your app under it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>genkit start <span class="nt">--</span> npx tsx <span class="nt">--watch</span> src/index.ts
</code></pre></div></div>

<p>That’s it. Genkit attaches to your running Node process, discovers every flow, prompt, model, tool, retriever, indexer, embedder and evaluator you have defined, and exposes them all in a local web app at <code class="language-plaintext highlighter-rouge">http://localhost:4000</code>.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Telemetry API running on http://localhost:4033
Genkit Developer UI: http://localhost:4000
</code></pre></div></div>

<p>What you actually get inside:</p>

<ul>
  <li><strong>Interactive runners for every primitive</strong>. Flows, prompts, tools, models, retrievers, indexers, embedders and evaluators all get an interactive panel where you fill out the input (validated against the Zod schema) and hit run.</li>
  <li><strong>Full traces</strong> with the entire generation graph: every model call, every tool invocation, every middleware, with input/output/usage tokens.</li>
  <li><strong>Live reload</strong>. Combined with <code class="language-plaintext highlighter-rouge">--watch</code>, edits to your code show up without restarting the UI.</li>
  <li><strong>Prompt iteration</strong>. Tweak prompts and re-run inline.</li>
  <li><strong>Evals</strong>. Run evaluators against datasets directly from the UI.</li>
  <li><strong>Open by default</strong>. Add <code class="language-plaintext highlighter-rouge">-o</code> to auto-open in your browser.</li>
</ul>

<p>What stands out here is that <strong>observability comes for free</strong>. You do not wrap your model and you do not add a middleware just to see traces. Anything defined with the Genkit primitives is automatically introspected and traced, which makes the “let me see what’s happening” cost essentially zero.</p>

<p>If you also use the <a href="/genkit/2026-05-13-genkit-middleware-v2/">middleware system</a>, every middleware also shows up in the trace, which makes debugging things like retries and fallbacks a lot easier.</p>

<h2 id="vercel-ai-sdk-devtools">Vercel AI SDK DevTools</h2>

<p>Vercel introduced the <a href="https://ai-sdk.dev/docs/ai-sdk-core/devtools">AI SDK DevTools</a> more recently, and it is a meaningful step up from having nothing. The design philosophy is different from Genkit’s: it is a <strong>middleware-based capture tool</strong> that focuses tightly on the language-model layer, rather than an integrated development environment for the whole AI app.</p>

<p>You opt in by wrapping your model:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">wrapLanguageModel</span><span class="p">,</span> <span class="nx">gateway</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ai</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">devToolsMiddleware</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@ai-sdk/devtools</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">model</span> <span class="o">=</span> <span class="nf">wrapLanguageModel</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nf">gateway</span><span class="p">(</span><span class="dl">'</span><span class="s1">anthropic/claude-sonnet-4.5</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">middleware</span><span class="p">:</span> <span class="nf">devToolsMiddleware</span><span class="p">(),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>And then run the viewer in another terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @ai-sdk/devtools
<span class="c"># open http://localhost:4983</span>
</code></pre></div></div>

<p>What it captures:</p>

<ul>
  <li>Input parameters and prompts.</li>
  <li>Output content and tool calls.</li>
  <li>Token usage and timing.</li>
  <li>Raw provider request/response payloads.</li>
  <li>Multi-step interactions are grouped into “runs” with multiple “steps”.</li>
</ul>

<p>Things to keep in mind when comparing it with the others:</p>

<ul>
  <li><strong>It is opt-in per model.</strong> Every model you want to observe has to be wrapped explicitly. In a real codebase with many models, this usually means a shared factory or repeated wrapping. If you forget on one path, you have a blind spot.</li>
  <li><strong>It focuses on the language-model layer.</strong> Anything happening above that layer (your own application logic, custom orchestration, business steps) is not captured unless you add it yourself.</li>
  <li><strong>It is a viewer, not an interactive playground.</strong> You cannot “re-run this with a different prompt” from the UI; you go back to your code, edit and re-call.</li>
  <li><strong>It does not enumerate AI primitives</strong> because, in the AI SDK, those are just functions in your codebase. There is nothing to enumerate at runtime.</li>
  <li><strong>Storage is a JSON file</strong> (<code class="language-plaintext highlighter-rouge">.devtools/generations.json</code>). The team is clear that this is local-only, never for production, and the middleware automatically appends <code class="language-plaintext highlighter-rouge">.devtools</code> to your <code class="language-plaintext highlighter-rouge">.gitignore</code>.</li>
</ul>

<p>The upside is that it is <strong>probably the quickest of the three to drop into an existing project</strong>: install the package, wrap a model, run the viewer. If you live inside Next.js and the AI SDK and you mainly want a “what just happened?” panel for your model calls, it does the job well. If you are debugging a multi-step agent with custom orchestration, you will probably want to combine it with something that sees more of the picture.</p>

<h2 id="mastra-studio">Mastra Studio</h2>

<p><a href="https://mastra.ai/docs/studio/overview">Mastra Studio</a> is the most ambitious of the three in terms of surface area. It is bundled with <code class="language-plaintext highlighter-rouge">mastra dev</code> and runs at <code class="language-plaintext highlighter-rouge">http://localhost:4111</code>, doubling as a local development tool <strong>and</strong> a deployable team console (you can ship Studio to production for non-developers).</p>

<p>Out of the box, it gives you:</p>

<ul>
  <li><strong>Agents tab</strong> — chat with your agents, hot-swap models, tweak temperature/top-p, view traces, attach scorers.</li>
  <li><strong>Workflows tab</strong> — visualize workflows as graphs, run them step by step with custom inputs, watch the active step in real time, inspect tool calls and JSON outputs.</li>
  <li><strong>Processors tab</strong> — see input/output processors and guardrails wired to each agent.</li>
  <li><strong>Tools tab</strong> — run tools in isolation to debug them.</li>
  <li><strong>Workspaces tab</strong> — file browser into the agent’s workspace filesystem, with a Skills tab listing discovered skills.</li>
  <li><strong>MCP servers tab</strong> — list attached MCP servers and their tools.</li>
  <li><strong>Request context</strong> — set runtime variables that flow into agent instructions/tools through DI, with schema-driven forms.</li>
  <li><strong>Evaluation suite</strong> — Scorers, Datasets and Experiments tabs to run datasets through agents/workflows, attach scorers and compare experiments side-by-side.</li>
  <li><strong>Observability</strong> with traces and logs.</li>
  <li><strong>Editor integration</strong> for non-technical teammates to iterate on agents and version every change without redeploying.</li>
</ul>

<p>This is more than a debugging tool — it is closer to a <strong>development environment plus a team-facing console</strong>. The natural trade-off is that Studio is tightly coupled to Mastra’s primitives (Agents, Workflows, Processors, Workspaces), so it shines the most when your application is structured the Mastra way.</p>

<p>If you build non-trivial agentic systems and you want evaluation and dataset management baked into the same tool, Studio is genuinely very good. For a quick “see my prompt and the model’s response” loop, it can feel like more than you need.</p>

<h2 id="side-by-side-comparison">Side-by-side comparison</h2>

<table>
  <thead>
    <tr>
      <th>Capability</th>
      <th>Genkit Developer UI</th>
      <th>Vercel AI SDK DevTools</th>
      <th>Mastra Studio</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Setup</td>
      <td><code class="language-plaintext highlighter-rouge">genkit start --</code> your code</td>
      <td><code class="language-plaintext highlighter-rouge">wrapLanguageModel(...)</code> + <code class="language-plaintext highlighter-rouge">npx @ai-sdk/devtools</code></td>
      <td><code class="language-plaintext highlighter-rouge">mastra dev</code></td>
    </tr>
    <tr>
      <td>Code changes required</td>
      <td>None (auto-discovers)</td>
      <td>Yes (wrap each model)</td>
      <td>None (auto-discovers Mastra primitives)</td>
    </tr>
    <tr>
      <td>Live reload</td>
      <td>Yes (<code class="language-plaintext highlighter-rouge">--watch</code>)</td>
      <td>N/A (viewer only)</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Interactive runners</td>
      <td>Flow, prompt, model, tool, retriever, indexer, embedder, evaluator</td>
      <td>None (viewer only)</td>
      <td>Agent chat, workflow runner, tool runner</td>
    </tr>
    <tr>
      <td>Traces</td>
      <td>Full generation graph</td>
      <td>Per-model-call run/step</td>
      <td>Workflow + agent traces</td>
    </tr>
    <tr>
      <td>Re-run / replay</td>
      <td>Yes, from any primitive</td>
      <td>No (must edit code and re-call)</td>
      <td>Yes, including workflow step-through</td>
    </tr>
    <tr>
      <td>Dataset / eval UI</td>
      <td>Evaluators panel</td>
      <td>Not built-in</td>
      <td>Datasets + Experiments + Scorers</td>
    </tr>
    <tr>
      <td>Workspace / files browser</td>
      <td>No</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>MCP server browser</td>
      <td>Not first-class</td>
      <td>No</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Deployable to team</td>
      <td>No (local-only)</td>
      <td>No (local-only)</td>
      <td>Yes (Studio on Mastra platform)</td>
    </tr>
    <tr>
      <td>Languages</td>
      <td>JS/TS (Go, Python, Dart all have local tooling too)</td>
      <td>JS/TS only</td>
      <td>JS/TS only</td>
    </tr>
    <tr>
      <td>Style</td>
      <td>Auto-discovery of primitives</td>
      <td>Per-model middleware capture</td>
      <td>All-in-one studio for the agent lifecycle</td>
    </tr>
  </tbody>
</table>

<p>A quick note on the Vercel column: simplicity of integration is a real feature for many teams, and it is fair to call this the lightest-touch option. The trade-off is that observability is opt-in per model, so you have to be deliberate to avoid blind spots.</p>

<h2 id="when-i-would-pick-which">When I would pick which</h2>

<p>After using all three, this is roughly how I’d think about it:</p>

<ul>
  <li><strong>Building a serious agent or multi-step pipeline with tools, retries, fallbacks and evals</strong> → <strong>Genkit Developer UI</strong> is hard to beat on pure iteration speed. Zero-config tracing of the full pipeline, every primitive runnable, and middleware shows up in the trace for free.</li>
  <li><strong>Working primarily inside Next.js with the Vercel AI SDK and you mainly want better visibility into model calls</strong> → <strong>Vercel AI SDK DevTools</strong> is a quick win. Lightweight setup and it does what it says on the tin; pair it with something else if you need higher-level orchestration views.</li>
  <li><strong>Building agentic systems with workflows, datasets and scorers, and you want a UI your PM or domain expert can also use</strong> → <strong>Mastra Studio</strong>. The evaluation suite and the deployability are real differentiators.</li>
  <li><strong>Multi-language stack</strong> (JS/TS plus Go, Python or Dart) → <strong>Genkit</strong> is the only one of the three with first-party multi-language support; the local tooling story is consistent across runtimes.</li>
</ul>

<h2 id="the-moral-of-the-story-pick-a-debugging-tool-any-debugging-tool">The moral of the story: pick a debugging tool, any debugging tool</h2>

<p>In a normal backend project, you can sometimes get away with a weak dev loop because the code is deterministic. Bad UX, but it works.</p>

<p>In an AI project, the code is not deterministic. Every iteration is also a small experiment. If your loop is slow or your visibility is poor, you don’t just iterate slower, <strong>you iterate worse</strong>, because you can’t tell whether your changes are improvements or regressions. Shift-left in this world is not a virtue, it is a survival strategy.</p>

<p>A good local debugging tool:</p>

<ul>
  <li>Turns “I think the prompt got better” into “I can see the trace, the tokens, the eval score, side by side”.</li>
  <li>Catches regressions before they reach a user.</li>
  <li>Lets you onboard new engineers in hours instead of days, because they can see the system.</li>
  <li>Lets non-engineers (PMs, designers, domain experts) participate in iteration when the UI is friendly enough — Mastra Studio is explicit about this.</li>
</ul>

<p>If you are starting a Gen AI project today and you are picking a framework, the local tooling story should weigh as much as the model abstraction or the tool API. It is the difference between writing AI code and <strong>engineering</strong> AI features.</p>

<h2 id="conclusion">Conclusion</h2>

<p>All three tools are good in their lane:</p>

<ul>
  <li><strong>Genkit Developer UI</strong> is the most introspective of the three and requires zero code changes to be useful. It’s the one I reach for when I want to move fast and see every step of the pipeline.</li>
  <li><strong>Vercel AI SDK DevTools</strong> is the lightest-touch option to drop into an existing AI SDK app, with a clear focus on the model-call layer. Think of it as a focused viewer rather than a full development environment.</li>
  <li><strong>Mastra Studio</strong> is the most ambitious, closer to a full IDE-meets-console for agentic systems, with first-class evaluation, datasets and a deployable team UI. It’s at its best when your app is structured around Mastra’s primitives.</li>
</ul>

<p>Pick the one that matches the shape of your project, but please pick something. Building Gen AI without a local debugging tool in 2026 is engineering with the lights off.</p>

<p>Further reading:</p>

<ul>
  <li><a href="https://genkit.dev/docs/js/devtools/">Genkit Developer Tools</a></li>
  <li><a href="https://ai-sdk.dev/docs/ai-sdk-core/devtools">Vercel AI SDK DevTools</a></li>
  <li><a href="https://mastra.ai/docs/studio/overview">Mastra Studio overview</a></li>
  <li><a href="/genkit/2026-04-16-top-jsts-genai-frameworks-2026/">Top JS/TS Gen AI Frameworks for 2026</a></li>
  <li><a href="/genkit/2026-05-13-genkit-middleware-v2/">Genkit Middleware deep dive</a></li>
</ul>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="genkit" /><category term="genkit" /><category term="vercel-ai-sdk" /><category term="mastra" /><category term="dev-ui" /><category term="devtools" /><summary type="html"><![CDATA[Building AI applications without a local debugging tool is like writing backend code without a debugger. A look at the "shift-left" philosophy applied to Gen AI development, and a hands-on comparison of Genkit Developer UI, Vercel AI SDK DevTools and Mastra Studio.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/genkit-dev-ui-comparison.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/genkit-dev-ui-comparison.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Genkit Middleware: Intercept, Extend and Harden your Gen AI Pipelines (English)</title><link href="https://xavidop.me/genkit/2026-05-13-genkit-middleware/" rel="alternate" type="text/html" title="Genkit Middleware: Intercept, Extend and Harden your Gen AI Pipelines (English)" /><published>2026-05-13T00:00:00+00:00</published><updated>2026-05-14T10:59:44+00:00</updated><id>https://xavidop.me/genkit/genkit-middleware</id><content type="html" xml:base="https://xavidop.me/genkit/2026-05-13-genkit-middleware/"><![CDATA[<ol class="no_toc" id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#what-is-middleware-in-genkit" id="markdown-toc-what-is-middleware-in-genkit">What is middleware in Genkit</a></li>
  <li><a href="#installation" id="markdown-toc-installation">Installation</a></li>
  <li><a href="#the-built-in-middleware-catalogue" id="markdown-toc-the-built-in-middleware-catalogue">The built-in middleware catalogue</a>    <ol>
      <li><a href="#filesystem--give-the-model-a-sandboxed-file-system" id="markdown-toc-filesystem--give-the-model-a-sandboxed-file-system"><code class="language-plaintext highlighter-rouge">filesystem</code> — give the model a sandboxed file system</a></li>
      <li><a href="#skills--auto-load-markdown-skills-as-system-context" id="markdown-toc-skills--auto-load-markdown-skills-as-system-context"><code class="language-plaintext highlighter-rouge">skills</code> — auto-load Markdown skills as system context</a></li>
      <li><a href="#toolapproval--human-in-the-loop-for-tool-calls" id="markdown-toc-toolapproval--human-in-the-loop-for-tool-calls"><code class="language-plaintext highlighter-rouge">toolApproval</code> — human-in-the-loop for tool calls</a></li>
      <li><a href="#retry--exponential-backoff-with-jitter-for-transient-errors" id="markdown-toc-retry--exponential-backoff-with-jitter-for-transient-errors"><code class="language-plaintext highlighter-rouge">retry</code> — exponential backoff with jitter for transient errors</a></li>
      <li><a href="#fallback--gracefully-degrade-to-a-different-model" id="markdown-toc-fallback--gracefully-degrade-to-a-different-model"><code class="language-plaintext highlighter-rouge">fallback</code> — gracefully degrade to a different model</a></li>
    </ol>
  </li>
  <li><a href="#building-your-own-middleware-with-generatemiddleware" id="markdown-toc-building-your-own-middleware-with-generatemiddleware">Building your own middleware with <code class="language-plaintext highlighter-rouge">generateMiddleware</code></a></li>
  <li><a href="#composition-stacking-middlewares" id="markdown-toc-composition-stacking-middlewares">Composition: stacking middlewares</a></li>
  <li><a href="#the-importance-of-middleware-for-production-agents" id="markdown-toc-the-importance-of-middleware-for-production-agents">The importance of middleware for production agents</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ol>

<h2 id="introduction">Introduction</h2>

<p>If you have been building anything non-trivial with Genkit, you have probably bumped into the same set of cross-cutting concerns over and over again: retrying transient model errors, falling back to a cheaper model when quota explodes, gating tool execution behind human approval, injecting filesystem access for coding agents, logging every request and response for observability…</p>

<p>Until now, you ended up either wrapping <code class="language-plaintext highlighter-rouge">ai.generate()</code> calls by hand or writing ad-hoc helpers that ended up duplicated across flows. The new <strong>Genkit Middleware</strong> changes that. It introduces a first-class, composable middleware layer for the <code class="language-plaintext highlighter-rouge">generate()</code> pipeline, with hooks for the <strong>model</strong>, the <strong>tool execution</strong> and the <strong>high-level generation loop</strong>, plus a small but very useful set of official middlewares published in the brand new <code class="language-plaintext highlighter-rouge">@genkit-ai/middleware</code> package.</p>

<p>This article is a practical tour of what the new middleware system gives you, the built-in middlewares you can drop in today, and how to write your own with <code class="language-plaintext highlighter-rouge">generateMiddleware</code>.</p>

<blockquote>
  <p>The official documentation lives at <a href="https://genkit.dev/docs/js/middleware/">Genkit Middleware</a>. All examples below assume the JavaScript/TypeScript SDK.</p>
</blockquote>

<blockquote>
  <p>A quick reminder: although this article focuses on the JS/TS middleware API, <strong>Genkit is a multi-language framework</strong>. The official SDKs cover <strong>JavaScript/TypeScript</strong> (primary, stable), <strong>Go</strong>, <strong>Python</strong> (preview) and <strong>Dart/Flutter</strong> (preview), and there is a community-maintained <strong>Java</strong> SDK used in production. The middleware concepts described here are JS/TS-specific today, but the underlying <code class="language-plaintext highlighter-rouge">generate()</code> pipeline exists across all SDKs and the same patterns are landing on the other runtimes.</p>
</blockquote>

<h2 id="what-is-middleware-in-genkit">What is middleware in Genkit</h2>

<p>Conceptually, Genkit middleware behaves like the middleware you already know from Express or Koa, only applied to the LLM lifecycle instead of HTTP requests:</p>

<ol>
  <li>A <code class="language-plaintext highlighter-rouge">generate()</code> call is intercepted before it reaches the model.</li>
  <li>Each middleware can inspect or modify the request, decide whether to call <code class="language-plaintext highlighter-rouge">next()</code>, and inspect or modify the response on the way back.</li>
  <li>Multiple middlewares can be chained. They run in the order they are declared and unwind in reverse order, exactly like an onion.</li>
</ol>

<p>What makes Genkit’s design interesting is that it does not give you a single chokepoint, it gives you <strong>three orthogonal interception phases</strong>:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">model</code></strong> — wraps the call to the underlying model. Perfect for retries, fallbacks, request/response logging or response transformations.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">tool</code></strong> — wraps tool execution. Ideal for approvals, sandboxing, audit logs or input/output validation.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">generate</code></strong> — wraps the whole high-level generation loop (prompting, tool calling, output parsing). Best for things like injecting tools or system instructions before the loop starts.</li>
</ul>

<p>You opt in per call via a <code class="language-plaintext highlighter-rouge">use:</code> array, which keeps things explicit and avoids global side effects:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Hello</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span><span class="nf">retry</span><span class="p">({</span> <span class="na">maxRetries</span><span class="p">:</span> <span class="mi">3</span> <span class="p">}),</span> <span class="nf">loggerMiddleware</span><span class="p">({</span> <span class="na">verbose</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})],</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="installation">Installation</h2>

<p>The official middlewares ship in their own package, decoupled from the Genkit core:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> @genkit-ai/middleware
<span class="c"># or</span>
pnpm add @genkit-ai/middleware
</code></pre></div></div>

<p>You still need <code class="language-plaintext highlighter-rouge">genkit</code> itself and a model provider plugin (for example <code class="language-plaintext highlighter-rouge">@genkit-ai/google-genai</code>).</p>

<h2 id="the-built-in-middleware-catalogue">The built-in middleware catalogue</h2>

<p>Let’s go through the five middlewares the Genkit team ships out of the box.</p>

<h3 id="filesystem--give-the-model-a-sandboxed-file-system"><code class="language-plaintext highlighter-rouge">filesystem</code> — give the model a sandboxed file system</h3>

<p><code class="language-plaintext highlighter-rouge">filesystem</code> injects a standard set of file manipulation tools (<code class="language-plaintext highlighter-rouge">list_files</code>, <code class="language-plaintext highlighter-rouge">read_file</code>, <code class="language-plaintext highlighter-rouge">write_file</code>, <code class="language-plaintext highlighter-rouge">search_and_replace</code>) into the generation loop, restricted to a root directory of your choice.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">genkit</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkit</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">googleAI</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/google-genai</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">filesystem</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/middleware</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">ai</span> <span class="o">=</span> <span class="nf">genkit</span><span class="p">({</span> <span class="na">plugins</span><span class="p">:</span> <span class="p">[</span><span class="nf">googleAI</span><span class="p">()]</span> <span class="p">});</span>

<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Create a hello world Node app in the workspace</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">filesystem</span><span class="p">({</span>
      <span class="na">rootDirectory</span><span class="p">:</span> <span class="dl">'</span><span class="s1">./workspace</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">allowWriteAccess</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="p">}),</span>
  <span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Useful options:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">rootDirectory</code> (required) — sandbox root, all paths are confined to it.</li>
  <li><code class="language-plaintext highlighter-rouge">allowWriteAccess</code> — defaults to <code class="language-plaintext highlighter-rouge">false</code>. Read-only by default is a sane choice for safety.</li>
  <li><code class="language-plaintext highlighter-rouge">toolNamePrefix</code> — namespace the injected tools to avoid collisions with your own.</li>
</ul>

<p>This is essentially the building block for a “coding agent” pattern, without you having to write tool definitions or path validation logic.</p>

<h3 id="skills--auto-load-markdown-skills-as-system-context"><code class="language-plaintext highlighter-rouge">skills</code> — auto-load Markdown skills as system context</h3>

<p><code class="language-plaintext highlighter-rouge">skills</code> scans a directory for <code class="language-plaintext highlighter-rouge">SKILL.md</code> files (plus their YAML frontmatter), injects relevant ones into the system prompt, and exposes a <code class="language-plaintext highlighter-rouge">use_skill</code> tool the model can call when it needs more specific guidance.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">skills</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/middleware</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">How do I run tests in this repo?</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span><span class="nf">skills</span><span class="p">({</span> <span class="na">skillPaths</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">./skills</span><span class="dl">'</span><span class="p">]</span> <span class="p">})],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Think of it as a lightweight, file-based knowledge layer: every skill is a self-contained Markdown file with metadata, and the middleware decides when to surface them. It is a really clean alternative to ad-hoc system prompt soup.</p>

<h3 id="toolapproval--human-in-the-loop-for-tool-calls"><code class="language-plaintext highlighter-rouge">toolApproval</code> — human-in-the-loop for tool calls</h3>

<p><code class="language-plaintext highlighter-rouge">toolApproval</code> enforces an allowlist of tools the model is allowed to execute autonomously. Anything outside the list raises a <code class="language-plaintext highlighter-rouge">ToolInterruptError</code>, so you can pause execution, ask the user, and resume.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">genkit</span><span class="p">,</span> <span class="nx">restartTool</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkit</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">toolApproval</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/middleware</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">write a file</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">tools</span><span class="p">:</span> <span class="p">[</span><span class="nx">writeFileTool</span><span class="p">],</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span><span class="nf">toolApproval</span><span class="p">({</span> <span class="na">approved</span><span class="p">:</span> <span class="p">[]</span> <span class="p">})],</span> <span class="c1">// empty list -&gt; always interrupt</span>
<span class="p">});</span>

<span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">finishReason</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">interrupted</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">interrupt</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">interrupts</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>

  <span class="c1">// ... ask the user, then mark the tool call as approved</span>
  <span class="kd">const</span> <span class="nx">approvedPart</span> <span class="o">=</span> <span class="nf">restartTool</span><span class="p">(</span><span class="nx">interrupt</span><span class="p">,</span> <span class="p">{</span> <span class="na">toolApproved</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>

  <span class="kd">const</span> <span class="nx">resumedResponse</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
    <span class="na">messages</span><span class="p">:</span> <span class="nx">response</span><span class="p">.</span><span class="nx">messages</span><span class="p">,</span>
    <span class="na">resume</span><span class="p">:</span> <span class="p">{</span> <span class="na">restart</span><span class="p">:</span> <span class="p">[</span><span class="nx">approvedPart</span><span class="p">]</span> <span class="p">},</span>
    <span class="na">use</span><span class="p">:</span> <span class="p">[</span><span class="nf">toolApproval</span><span class="p">({</span> <span class="na">approved</span><span class="p">:</span> <span class="p">[]</span> <span class="p">})],</span>
  <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is exactly the pattern you want for any agent that touches the real world (filesystem writes, payments, sending emails). No more home-grown approval flags scattered across the codebase.</p>

<h3 id="retry--exponential-backoff-with-jitter-for-transient-errors"><code class="language-plaintext highlighter-rouge">retry</code> — exponential backoff with jitter for transient errors</h3>

<p>The <code class="language-plaintext highlighter-rouge">retry</code> middleware retries failed model calls on transient status codes (<code class="language-plaintext highlighter-rouge">UNAVAILABLE</code>, <code class="language-plaintext highlighter-rouge">DEADLINE_EXCEEDED</code>, <code class="language-plaintext highlighter-rouge">RESOURCE_EXHAUSTED</code>, <code class="language-plaintext highlighter-rouge">ABORTED</code>, <code class="language-plaintext highlighter-rouge">INTERNAL</code>) using exponential backoff with jitter.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">retry</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/middleware</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-pro-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Heavy reasoning task...</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">retry</span><span class="p">({</span>
      <span class="na">maxRetries</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
      <span class="na">initialDelayMs</span><span class="p">:</span> <span class="mi">1000</span><span class="p">,</span>
      <span class="na">backoffFactor</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
    <span class="p">}),</span>
  <span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Knobs you actually care about:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">maxRetries</code> (default <code class="language-plaintext highlighter-rouge">3</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">statuses</code> — which status codes to retry on</li>
  <li><code class="language-plaintext highlighter-rouge">initialDelayMs</code> / <code class="language-plaintext highlighter-rouge">maxDelayMs</code> / <code class="language-plaintext highlighter-rouge">backoffFactor</code></li>
  <li><code class="language-plaintext highlighter-rouge">noJitter</code> — if you really want deterministic delays</li>
</ul>

<p>This is one of those things every team writes once, badly. Having it in the framework is a very welcome change.</p>

<h3 id="fallback--gracefully-degrade-to-a-different-model"><code class="language-plaintext highlighter-rouge">fallback</code> — gracefully degrade to a different model</h3>

<p><code class="language-plaintext highlighter-rouge">fallback</code> switches to an alternate model when the primary one fails on configurable status codes. The classic use case is “try Pro first, fall back to Flash when quota is exhausted”:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">fallback</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@genkit-ai/middleware</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-pro-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Try the pro model first...</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">fallback</span><span class="p">({</span>
      <span class="na">models</span><span class="p">:</span> <span class="p">[</span><span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">)],</span>
      <span class="na">statuses</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">RESOURCE_EXHAUSTED</span><span class="dl">'</span><span class="p">],</span>
    <span class="p">}),</span>
  <span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>You can chain multiple fallback models, and <code class="language-plaintext highlighter-rouge">isolateConfig</code> lets you decide whether the fallback inherits the original request configuration or starts clean (handy when the fallback model does not support the same options as the primary).</p>

<h2 id="building-your-own-middleware-with-generatemiddleware">Building your own middleware with <code class="language-plaintext highlighter-rouge">generateMiddleware</code></h2>

<p>The same primitive that powers all the built-ins is exposed for you. The <code class="language-plaintext highlighter-rouge">generateMiddleware</code> helper gives you typed config schemas (via Zod) and access to the <code class="language-plaintext highlighter-rouge">ai</code> instance.</p>

<p>Here is the canonical “logger” example, straight from the docs but lightly annotated:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">generateMiddleware</span><span class="p">,</span> <span class="nx">z</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">genkit</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">loggerMiddleware</span> <span class="o">=</span> <span class="nf">generateMiddleware</span><span class="p">(</span>
  <span class="p">{</span>
    <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">loggerMiddleware</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">description</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Logs requests and responses</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">configSchema</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">object</span><span class="p">({</span>
      <span class="na">verbose</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">boolean</span><span class="p">().</span><span class="nf">optional</span><span class="p">(),</span>
    <span class="p">}),</span>
  <span class="p">},</span>
  <span class="p">({</span> <span class="nx">config</span><span class="p">,</span> <span class="nx">ai</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="c1">// Phase 1: intercept the model call</span>
      <span class="na">model</span><span class="p">:</span> <span class="k">async </span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">config</span><span class="p">?.</span><span class="nx">verbose</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Request:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">req</span><span class="p">));</span>
        <span class="p">}</span>
        <span class="kd">const</span> <span class="nx">resp</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">next</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">);</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">config</span><span class="p">?.</span><span class="nx">verbose</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">Response:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">resp</span><span class="p">));</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="nx">resp</span><span class="p">;</span>
      <span class="p">},</span>
      <span class="c1">// You could also add `tool: ...` and `generate: ...` hooks here.</span>
    <span class="p">};</span>
  <span class="p">}</span>
<span class="p">);</span>
</code></pre></div></div>

<p>Using it is identical to the official ones:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Hello</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span><span class="nf">loggerMiddleware</span><span class="p">({</span> <span class="na">verbose</span><span class="p">:</span> <span class="kc">true</span> <span class="p">})],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>A few patterns I have found very useful:</p>

<ul>
  <li><strong>PII redaction</strong> — implement a <code class="language-plaintext highlighter-rouge">model</code> hook that scrubs the request prompt and the response text against a regex/dictionary, returning the cleaned version.</li>
  <li><strong>Cost accounting</strong> — wrap the <code class="language-plaintext highlighter-rouge">model</code> hook to read <code class="language-plaintext highlighter-rouge">usage</code> tokens from the response, and emit them to your metrics backend tagged by user/feature.</li>
  <li><strong>Per-tenant quotas</strong> — use the <code class="language-plaintext highlighter-rouge">generate</code> hook to check a counter (Redis, Firestore…) before calling <code class="language-plaintext highlighter-rouge">next()</code>; throw your own custom error if the tenant is over quota.</li>
  <li><strong>Caching</strong> — keyed on a hash of the model + request, return a cached response if hit, otherwise call <code class="language-plaintext highlighter-rouge">next()</code> and persist the result.</li>
</ul>

<p>For more inspiration, the source of the official middlewares is open in the <a href="https://github.com/genkit-ai/genkit/tree/main/js/plugins/middleware">Genkit GitHub repository</a>, and reading them is genuinely educational.</p>

<h2 id="composition-stacking-middlewares">Composition: stacking middlewares</h2>

<p>Middlewares compose in array order. A reasonable production stack might look like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">ai</span><span class="p">.</span><span class="nf">generate</span><span class="p">({</span>
  <span class="na">model</span><span class="p">:</span> <span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-pro-latest</span><span class="dl">'</span><span class="p">),</span>
  <span class="na">prompt</span><span class="p">:</span> <span class="nx">userPrompt</span><span class="p">,</span>
  <span class="na">tools</span><span class="p">:</span> <span class="nx">myTools</span><span class="p">,</span>
  <span class="na">use</span><span class="p">:</span> <span class="p">[</span>
    <span class="nf">loggerMiddleware</span><span class="p">({</span> <span class="na">verbose</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}),</span>       <span class="c1">// outermost: see everything</span>
    <span class="nf">retry</span><span class="p">({</span> <span class="na">maxRetries</span><span class="p">:</span> <span class="mi">3</span> <span class="p">}),</span>                   <span class="c1">// recover from transient failures</span>
    <span class="nf">fallback</span><span class="p">({</span>                                  <span class="c1">// degrade if Pro is overloaded</span>
      <span class="na">models</span><span class="p">:</span> <span class="p">[</span><span class="nx">googleAI</span><span class="p">.</span><span class="nf">model</span><span class="p">(</span><span class="dl">'</span><span class="s1">gemini-flash-latest</span><span class="dl">'</span><span class="p">)],</span>
      <span class="na">statuses</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">RESOURCE_EXHAUSTED</span><span class="dl">'</span><span class="p">],</span>
    <span class="p">}),</span>
    <span class="nf">toolApproval</span><span class="p">({</span> <span class="na">approved</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">searchDocs</span><span class="dl">'</span><span class="p">]</span> <span class="p">}),</span> <span class="c1">// gate dangerous tools</span>
  <span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The order matters: outer middlewares see the result of the inner ones. Put logging on the outside if you want it to record the final state after retries and fallbacks; put it on the inside if you want to see every individual model attempt.</p>

<h2 id="the-importance-of-middleware-for-production-agents">The importance of middleware for production agents</h2>

<p>Genkit Middleware is one of those features that does not look flashy in a changelog but quietly fixes a lot of real-world friction. It pushes Genkit closer to a “batteries-included” framework for production agents:</p>

<ul>
  <li>Cross-cutting concerns are no longer copy-pasted across flows.</li>
  <li>Safety-critical behavior (approvals, sandboxes, fallbacks) is declarative.</li>
  <li>The <code class="language-plaintext highlighter-rouge">model</code> / <code class="language-plaintext highlighter-rouge">tool</code> / <code class="language-plaintext highlighter-rouge">generate</code> split gives you precise control without forcing you to monkey-patch.</li>
  <li>The middleware contract is small enough that the community can ship plugins that interoperate.</li>
</ul>

<p>If you maintain any non-trivial Genkit application, the upgrade is a no-brainer. Drop in <code class="language-plaintext highlighter-rouge">retry</code> and <code class="language-plaintext highlighter-rouge">fallback</code> first, you will probably see incidents disappear within the week. Then start writing your own middlewares for the things that are unique to your domain.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Middleware turns Genkit’s <code class="language-plaintext highlighter-rouge">generate()</code> from “a function you call” into “a pipeline you compose”. The official <code class="language-plaintext highlighter-rouge">@genkit-ai/middleware</code> package covers the most common production needs (filesystem access, skills, tool approval, retries, fallbacks), and <code class="language-plaintext highlighter-rouge">generateMiddleware</code> makes writing your own a 20-line affair instead of a refactor.</p>

<p>For the next steps, take a look at:</p>

<ul>
  <li><a href="https://genkit.dev/docs/js/middleware/">Genkit Middleware documentation</a></li>
  <li><a href="https://github.com/genkit-ai/genkit/tree/main/js/plugins/middleware">Genkit middleware source on GitHub</a></li>
  <li><a href="https://genkit.dev/docs/js/flows/">Genkit flows</a> — middleware composes especially well with typed flows</li>
  <li><a href="https://genkit.dev/docs/js/tool-calling/">Tool calling</a> and <a href="https://genkit.dev/docs/js/interrupts/">Interrupts</a> — the foundation that <code class="language-plaintext highlighter-rouge">toolApproval</code> builds on</li>
</ul>

<p>Happy hacking, and may your fallback models always be cheaper than your primary one.</p>]]></content><author><name>Xavier Portilla Edo</name><email>xavierportillaedo@gmail.com</email></author><category term="genkit" /><category term="genkit" /><category term="javascript" /><category term="typescript" /><category term="middleware" /><category term="gemini" /><summary type="html"><![CDATA[A deep dive into the new Genkit middleware system for JavaScript/TypeScript: built-in middleware (filesystem, skills, toolApproval, retry, fallback), how to build your own with `generateMiddleware`, and the new `model`/`tool`/`generate` interception hooks.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://xavidop.me/assets/img/blog/post-headers/genkit-middleware.png" /><media:content medium="image" url="https://xavidop.me/assets/img/blog/post-headers/genkit-middleware.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>