<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Pasi Huuhka - Azure Deep Dive]]></title><description><![CDATA[DevOps & Coding on Azure]]></description><link>https://www.huuhka.net/</link><image><url>https://www.huuhka.net/favicon.png</url><title>Pasi Huuhka - Azure Deep Dive</title><link>https://www.huuhka.net/</link></image><generator>Ghost 5.80</generator><lastBuildDate>Thu, 30 Apr 2026 18:42:00 GMT</lastBuildDate><atom:link href="https://www.huuhka.net/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Building your own PR reviewer with coding agents]]></title><description><![CDATA[I've now built a couple versions of automated PR reviewers, and the main thing that keeps standing out is that the AI part is surprisingly small.
The model matters, of course. But the architecture matters more. ]]></description><link>https://www.huuhka.net/building-your-own-pr-reviewer-with-coding-agents/</link><guid isPermaLink="false">69d94b79313b720001df3e28</guid><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[GitHub Copilot]]></category><category><![CDATA[OpenCode]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Wed, 11 Mar 2026 20:28:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/101930_arch.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Agentic Dev theme:<br>- <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/" rel="noreferrer">A mental model for LLM tooling primitives</a><br>- <a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">Research - Plan - Implement</a><br>- <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/" rel="noreferrer">Primary vs Subagents in LLM harnesses</a><br>- <a href="https://www.huuhka.net/how-i-currently-develop-with-llm-models-early-2026/" rel="noreferrer">How I currently develop with LLM models (Early 2026)</a> <br>- <a href="https://www.huuhka.net/building-your-own-pr-reviewer-with-coding-agents/" rel="noreferrer">Building your own PR reviewer with coding agents</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/101930_arch.png" alt="Building your own PR reviewer with coding agents"><p>I&apos;ve now built a couple versions of automated PR reviewers, and the main thing that keeps standing out is that the AI part is surprisingly small.</p><p>That may sound odd for a post about AI review agents, but in practice most of the system is normal software engineering. You receive an event, decide whether to do anything, gather context, start an isolated run, call the model through a coding harness, parse the result, and post findings back to the source system.</p><p>The model matters, of course. But the architecture matters more. </p><p>This post is about that architecture. I&apos;ll use <a href="https://github.com/github/copilot-sdk?ref=huuhka.net">GitHub Copilot SDK</a> in the examples for the LLM side, because it maps cleanly to this kind of coding-agent workflow.  The overall pattern works just as well with other harnesses or plain CLIs.</p><p>It builds on a few earlier posts, especially <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/">A mental model for LLM tooling primitives</a> and <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/">Primary vs Subagents in LLM harnesses</a>.</p><h3 id="the-short-version">The short version<br></h3><ul><li><strong>Mostly control flow, not AI.</strong> A PR bot is normal event-driven software with an LLM in one stage.</li><li><strong>The agent reviews, it does not run the business process.</strong> Everything around the review should be ordinary code.</li><li><strong>Event-driven is the natural shape.</strong> Receive the PR event, gather context, run a contained review, post structured results back.</li><li><strong>Coding harnesses fit well</strong> because they already know how to read files, inspect repositories, and run commands.</li><li><strong>Structured output matters.</strong> The model&apos;s findings should be easy for your code to validate before posting.</li><li><strong>The same architecture generalizes.</strong> Any AI automation with a clear start, process and end can use this shape. You could just as easily build on GitHub Actions, <em>(edit on 23.3.2026) or even the new </em><a href="https://github.github.com/gh-aw/?ref=huuhka.net"><em>Agentic Workflows</em></a><em> to do the same.</em></li></ul><h3 id="the-bot-itself-is-simple">The bot itself is simple</h3><p>At a high level, a PR reviewer is not a complicated product.</p><p>Something happens in your source system. A pull request is created, updated, commented on, or explicitly flagged for review. Your system decides whether that event should trigger a review, gathers the repository context, runs the review logic, and posts the findings back.</p><p>That sounds almost boring, and that is exactly the point. This is one of those cases where I strongly think the key to good LLM systems is making them as deterministic as possible. Do not let the agent decide what process it is in. Let code do that.</p><p>The agent&apos;s job is to review the change. Everything around that should be ordinary software.</p><h3 id="triggering-the-review">Triggering the review</h3><p>There are a few obvious ways to start:</p><ul><li><strong>Always review on PR creation.</strong> Simple and broad.</li><li><strong>Review on creation and updates. </strong>Covers iteration.</li><li><strong>Only review on an explicit command.</strong> Controls cost and noise.</li><li><strong>Trigger from a UI action.</strong> Product-specific entry point.</li></ul><p>This is mostly a product decision, not an AI decision. You can also mix approaches: a lightweight default review plus deeper specialist reviews on demand.</p><p>The triggering event can be almost anything. I use PR events a lot, but this same architecture works for issue triage, ticket classification, bug reproduction, documentation generation, or anything else where a system event kicks off a bounded piece of work.</p><h3 id="ado-service-hooks-as-the-integration-point">ADO service hooks as the integration point</h3><p>In Azure DevOps, <a href="https://learn.microsoft.com/en-us/azure/devops/service-hooks/overview?view=azure-devops&amp;ref=huuhka.net">service hooks</a> are the natural fit. They are essentially an event subscription mechanism: a publisher emits an event, a subscription filters it, and a <a href="https://learn.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops&amp;ref=huuhka.net">webhooks consumer</a> sends a JSON payload to your HTTPS endpoint.</p><p>For PR automation, the interesting <a href="https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops&amp;ref=huuhka.net">events</a> are:</p><ul><li>pull request created</li><li>pull request updated</li><li>pull request commented on</li><li>pull request merge attempted</li></ul><p>Azure DevOps lets you control how much resource detail goes into the webhook payload. I prefer smaller payloads and fetching the full PR details myself afterward. That keeps the webhook receiver simpler and forces context gathering into one consistent path.</p><h3 id="architecture-over-model">Architecture over model</h3><p>The shape I&apos;ve been using is fairly simple:</p><p>1. A source system event arrives.</p><p>2. A manager layer classifies whether work should start.</p><p>3. The manager gathers enough context to create a review job.</p><p>4. The manager stores run state in a database.</p><p>5. The manager starts an isolated worker run.</p><p>6. The worker performs the LLM review and returns structured output.</p><p>7. The manager parses the result and posts comments or findings back to the source system.</p><p>8. The manager updates run state.</p><p>Notice the LLM is only a small part of the whole pipeline, and that the logic is no rocket science.</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/101929_arch.png" class="kg-image" alt="Building your own PR reviewer with coding agents" loading="lazy" width="2000" height="606"></figure><p>My own implementation has used <a href="https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?ref=huuhka.net">Azure Functions</a> as the management layer and <a href="https://learn.microsoft.com/en-us/azure/container-apps/jobs?ref=huuhka.net">Azure Container Apps jobs</a> for the isolated execution. The function receives the Azure DevOps webhook and starts a container app job for each review. I like that shape because each review is contained. It has its own execution, logs, and failure boundary.</p><p>The other option I find interesting is using a microVM-based sandbox. That starts to matter if you want easier session resumption later, or if you want nested container execution inside the sandbox. For simple review flows, a container job is usually enough. MicroVMs are definitely taking off in the industry, as many services serving quickly spawning isolated sandboxes are popping up everywhere. They&apos;re something I&apos;ve been planning to build on ever since <a href="https://builders.ramp.com/post/why-we-built-our-background-agent?ref=huuhka.net">reading this post by Ramp</a>. The main benefit in my mind is the easy ability to also run the app in a container inside the microVM.</p><p>For state, almost anything works. I&apos;ve used table storage and that has been completely fine. If all you really need is run state, correlation IDs, status, and posted-result metadata, you do not need an especially fancy database.</p><h3 id="the-ai-is-one-stage">The AI is one stage</h3><p>The AI stage does not need to own the whole review process. It does not need to decide when jobs start, how retries work, where state lives, how comments are posted, or how webhook idempotency is handled. That is all normal application logic.</p><p>The LLM&apos;s job is much smaller:</p><ul><li><strong>Inspect</strong> the repository and PR context</li><li><strong>Review</strong> the change</li><li><strong>Return</strong> structured findings</li></ul><p>I think the healthiest mental model for these bounded automations is to treat the LLM like another API dependency. The smaller and more bounded the use case, the tighter the guardrails should be. If the task is wider and more exploratory, you can relax them.</p><h3 id="why-coding-harnesses-fit">Why coding harnesses fit</h3><p>PR review is one of the places where coding harnesses are a very natural fit. The model needs exactly the kind of capabilities those harnesses already provide: <strong>Read files</strong> and inspect diffs, <strong>Search</strong> the codebase and <strong>Optionally run commands</strong> like linting, type checking or project-specific validations.</p><p>That is why this works best with a coding harness instead of a thin text-only wrapper around an LLM API.</p><p>I&apos;ve implemented this with both Copilot SDK and <a href="https://opencode.ai/docs/sdk/?ref=huuhka.net">OpenCode SDK</a>, and honestly even using the CLIs directly can work if your process is simple enough. The important bit is not the specific SDK. It is that the runtime already understands code-oriented tools.</p><p>Depending on your trust model, you may also want to let the review agent run bash commands. That can improve review quality quite a bit, but obviously also pushes you toward stronger isolation and permission handling. Pure review type of work is probably fine without command execution, but if you want to get into fix suggestions and validation, it becomes more important.</p><h3 id="own-the-orchestration-not-the-control-flow">Own the orchestration, not the control flow</h3><p>This is where the 12-factor agents point becomes especially useful. </p><p>Your code should decide:</p><ul><li><strong>When</strong> a review starts and whether the event is eligible</li><li><strong>What</strong> repository or commit range to inspect</li><li><strong>Which</strong> agent setup to use</li><li><strong>How</strong> retries, deduplication, timeouts and result posting work</li></ul><p>The LLM should decide:</p><ul><li><strong>Whether</strong> a change looks risky or a security issue exists</li><li><strong>Whether</strong> tests appear missing</li><li><strong>How</strong> findings should be summarized</li></ul><p>That separation makes the whole system easier to reason about and easier to trust.</p><h3 id="inside-the-llm-run">Inside the LLM run</h3><p>Inside the review run itself, I don&apos;t really like having one giant agent do everything.<br>What has worked better for me is a primary reviewer that fans out to specialists in parallel, then synthesizes their output:</p><ul><li><strong>One primary reviewer agent</strong></li><li><strong>Several specialist subagents in parallel</strong> (one skill per specialist)</li><li><strong>One final synthesis step</strong> that produces structured review output</li></ul><figure class="kg-card kg-image-card kg-width-wide"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/101929_llm.png" class="kg-image" alt="Building your own PR reviewer with coding agents" loading="lazy" width="3205" height="1209"></figure><p>This maps pretty directly to the primitives I wrote about earlier. The command contains instructions on what to do. The agents contain instructions on how to do it. The skills package reusable domain guidance for a specific specialist.</p><p>In practice, the specialists might be for example an **<strong>Architecture reviewer</strong>**, **<strong>Testing reviewer</strong>**, **<strong>Security reviewer</strong>**, **<strong>Project-specific reviewer</strong>**, or whatever makes the most sense for your codebase and team. </p><p>Arguably, you could go even further and remove the primary reviewer and push the review request to the specialists directly, and just synthesize their output afterward. That&apos;s what I&apos;m doing in the later example, but both approaches work well.</p><p>The exact number depends on the system. A tiny one-file change does not need a small army of subagents. If the review scope is narrow, one agent is often enough. If the review scope is broader and the work can run in parallel, multiple specialists make more sense. That parallelism usually helps latency more than it hurts, though of course it increases cost.</p><h3 id="copilot-sdk-example">Copilot SDK example</h3><p>The Copilot SDK is a good fit for this because it exposes the same runtime behind Copilot CLI through a programmatic interface. The SDK talks to the CLI over JSON-RPC, and you create sessions that can use built-in coding tools, custom agents, skills, MCP servers, and hooks.</p><p>The useful part is not anything magical. You define the session once and then ask it to do exactly one bounded review task.</p><p>Here is a very simplified (and somewhat incomplete) TypeScript example:</p><pre><code class="language-ts">import { CopilotClient } from &quot;@github/copilot-sdk&quot;;

const client = new CopilotClient();
await client.start();

const outputShape = `
{
  &quot;summary&quot;: &quot;string&quot;,
  &quot;findings&quot;: [
    {
      &quot;severity&quot;: &quot;critical|high|medium|low&quot;,
      &quot;title&quot;: &quot;string&quot;,
      &quot;path&quot;: &quot;string&quot;,
      &quot;line&quot;: 123,
      &quot;body&quot;: &quot;string&quot;
    }
  ]
}`;

const reviewTask = `
Review this pull request in the current repository checkout.

Focus only on concrete issues in the changed code.
Use repository tools as needed.
Return JSON only in this shape:

${outputShape}
`;

const customAgents = [
  {
    name: &quot;architecture-reviewer&quot;,
    description: &quot;Reviews architecture and maintainability risks&quot;,
    tools: [&quot;grep&quot;, &quot;glob&quot;, &quot;view&quot;, &quot;bash&quot;],
    prompt: `
Review the pull request from an architecture perspective.

Focus on boundaries, coupling, maintainability, layering, and long-term code health.

${reviewTask}
`,
    infer: false,
  },
  {
    name: &quot;security-reviewer&quot;,
    description: &quot;Reviews security issues and dangerous patterns&quot;,
    tools: [&quot;grep&quot;, &quot;glob&quot;, &quot;view&quot;, &quot;bash&quot;],
    prompt: `
Review the pull request from a security perspective.

Focus on authentication, authorization, secrets handling, injection, trust boundaries, and unsafe execution patterns.

${reviewTask}
`,
    infer: false,
  },
];

async function runReviewer(agent: string) {
  const session = await client.createSession({
    model: &quot;gpt-4.1&quot;,
    agent,
    customAgents,
    onPermissionRequest: async () =&gt; ({ kind: &quot;approved&quot; }),
  });

  try {
    const response = await session.sendAndWait({ prompt: reviewTask });
    return JSON.parse(response?.data.content ?? &apos;{&quot;summary&quot;:&quot;&quot;,&quot;findings&quot;:[]}&apos;);
  } finally {
    await session.disconnect();
  }
}

const specialistReviews = await Promise.all([
  runReviewer(&quot;architecture-reviewer&quot;),
  runReviewer(&quot;security-reviewer&quot;),
]);

const synthesis = await client.createSession({
  model: &quot;gpt-4.1&quot;,
  onPermissionRequest: async () =&gt; ({ kind: &quot;approved&quot; }),
});

try {
  const response = await synthesis.sendAndWait({
    prompt: `
You are the parent PR reviewer.

Merge overlapping findings from these specialist reviews and return one final review.

${JSON.stringify(specialistReviews, null, 2)}

Return JSON only in this shape:

${outputShape}
`,
  });

  console.log(
    JSON.stringify(
      JSON.parse(response?.data.content ?? &apos;{&quot;summary&quot;:&quot;&quot;,&quot;findings&quot;:[]}&apos;),
      null,
      2,
    ),
  );
} finally {
  await synthesis.disconnect();
  await client.stop();
}</code></pre><h3 id="skills-agents-and-structured-output">Skills, agents and structured output</h3><p>I would not build this around a single giant system prompt. The split I like is:</p><ul><li><strong>Command or job instructions: </strong> what the current run should do</li><li><strong>Agent prompts:</strong> what each specialist is responsible for</li><li><strong>Skills:</strong> reusable guidance for how that specialist should operate</li></ul><p>The Copilot SDK <a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/use-copilot-sdk/custom-agents?ref=huuhka.net">custom agent support</a> and <a href="https://github.com/github/copilot-sdk/blob/main/docs/features/skills.md?ref=huuhka.net">skill loading</a> fit that pattern well. But you can implement this any way that gets the content to the agents in a clear way. I like skills because they can be reused by the developers locally as well, especially the project specific skills are easily shared between the review agent and human developers.</p><p>For any automation, the final output should be structured to be easily machine-readable for further processing.</p><h3 id="reviews-get-wordy">Reviews get wordy</h3><p>One thing I have definitely noticed is that review agents get verbose very quickly.<br>Current models produce useful output, but they also happily produce a lot of it. Without constraints on format and severity, you easily end up with comments that are technically fine but may be nitpicks or things not worth blocking a PR over.  </p><p>This can quickly turn into developer toil especially if you have set branch policies to require all comments be reviewed before completing the PR merge.</p><p>That is why I increasingly prefer adding explicit severity levels. That will also allow you to filter out low-severity findings or even automatically post them as a comment on the PR without flagging them as a review issue, or maybe combining them into a single comment. Even better, maybe you can even start jobs to fix smaller issues automatically based on the severity ranking and complexity of the fix.</p><p>The interesting question is: which classes of findings are safe enough to autofix?<br>This then turns from &quot;review bot&quot; into a more general automation system. For that to work well, the agent likely needs to edit code, run tests and validations, and verify that the fix actually worked. Very doable, but it does mean the isolation, permissions and verification side of the architecture matter even more.</p><h3 id="the-same-architecture-powers-a-fix-command">The same architecture powers a fix command</h3><p>I&apos;ve also implemented a fix command with basically the same structure. The trigger and prompt are different, but the architecture is almost identical. The system receives an event or command (say, a comment with &quot;/fix HOW TO FIX THIS&quot;), gathers repo, PR and comment thread context, starts an isolated run, lets the coding agent perform a bounded task, and returns a structured result with a follow-up action like creating a PR or commit with the fix.</p><p>Once you have the manager, state, isolated execution, and source-system callback flow working, you can reuse it for a lot of neighboring automations.</p><h3 id="local-and-remote-on-the-same-harness">Local and remote on the same harness</h3><p>One thing I think is genuinely valuable is building these automations on top of the same harness you use locally, like OpenCode for example. If the same agents, skills, instructions and tool configuration also work in local development, you get some nice properties: faster feedback before code ever reaches a PR, easier debugging of the automation itself, reusable review specialists as subagents during development, and less drift between local and server-side AI workflows.<br>That also creates tradeoffs. The structure ideal for local interactive use is not always identical to what you want for a remote webhook-driven automation. If you want both, you need to think about how to organize prompts, skills, state and configuration so they fit both cases.</p><h3 id="this-generalizes">This generalizes</h3><p>This pattern is not specific to PR review. It works well anywhere the automation has a clear triggering event, a bounded piece of reasoning work, and a structured result to return.</p><p>That is why I increasingly think of these automations as normal event-driven systems with an LLM inside one stage of the pipeline. The more tightly scoped the task, the more deterministic everything around the model should be.</p><h3 id="wrap-up">Wrap up</h3><p>If I had to compress the whole thing: build it like a normal event-driven system and let code own the workflow. Let the model own the review judgment, not the process. Use a coding harness so the agent can inspect the real repo properly. Return structured findings, not just prose. And keep the task bounded and deterministic where possible.</p><p>The most important design choice is often not which model you use. It is how much of the control flow you refuse to hand over.<br></p>]]></content:encoded></item><item><title><![CDATA[Testing Cloudflare's Code Mode on Azure DevOps MCP]]></title><description><![CDATA[I recently spent some time testing Cloudflare's Code Mode in implementing the Azure DevOps MCP server in a more lightweight manner. This post goes through some of my experiences.]]></description><link>https://www.huuhka.net/testing-cloudflares-code-mode-on-azure-devops-mcp/</link><guid isPermaLink="false">69ac9d7a26d9b800016695f3</guid><category><![CDATA[AI]]></category><category><![CDATA[Azure DevOps]]></category><category><![CDATA[GitHub Copilot]]></category><category><![CDATA[OpenCode]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Sat, 07 Mar 2026 21:37:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91712_final-flow.png" medium="image"/><content:encoded><![CDATA[<img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91712_final-flow.png" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP"><p>I recently spent some time testing <a href="https://github.com/cloudflare/agents/tree/main/packages/codemode?ref=huuhka.net">Cloudflare&apos;s Code Mode</a> in implementing the Azure DevOps MCP server in a more lightweight manner.</p><p><a href="https://github.com/microsoft/azure-devops-mcp?ref=huuhka.net" rel="noreferrer">Azure DevOps MCP</a> exposes <a href="https://github.com/microsoft/azure-devops-mcp/blob/main/docs/TOOLSET.md?ref=huuhka.net" rel="noreferrer">a lot of tools</a> by default. In my environment that surface was around 80 tools. That is already enough that normal tool calling starts to feel awkward. You spend a lot of tokens just describing the tools, the model has to select from a broad surface, and once you need multiple calls in sequence the whole thing can get noisy very quickly.</p><p>So the pitch of Code Mode made immediate sense to me: Instead of forcing the model to pick one tool at a time, let it write a small program that does the orchestration itself.</p><p>That is also very much in line with where the broader ecosystem seems to be moving. Anthropic now has both <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool?ref=huuhka.net">tool search</a> and <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling?ref=huuhka.net">programmatic tool calling</a>. OpenAI has <a href="https://developers.openai.com/api/docs/guides/tools-tool-search/?ref=huuhka.net">tool search</a> as well. Anthropic&apos;s own writeup on this direction is worth reading too: <a href="https://www.anthropic.com/engineering/advanced-tool-use?ref=huuhka.net">Introducing advanced tool use on the Claude Developer Platform</a>.</p><p>So this post is not really about whether the idea is good. I think it is. It is about what happened when I actually tried to make it work on a real tool surface that looked like an obvious candidate.</p><h3 id="what-code-mode-is-in-the-simplest-form">What Code Mode is, in the simplest form</h3><p><a href="https://github.com/cloudflare/agents/tree/main/packages/codemode?ref=huuhka.net#readme" rel="noreferrer">Cloudflare&apos;s own README</a> gives a very small example of the pattern:</p><pre><code class="language-ts">
import { createCodeTool } from &quot;@cloudflare/codemode/ai&quot;;
import { DynamicWorkerExecutor } from &quot;@cloudflare/codemode&quot;;
import { streamText, tool } from &quot;ai&quot;;
import { z } from &quot;zod&quot;;

// Create the tools
const tools = {
  getWeather: tool({
    description: &quot;Get weather for a location&quot;,
    inputSchema: z.object({ location: z.string() }),
    execute: async ({ location }) =&gt; `Weather in ${location}: 72&#xB0;F, sunny`
  }),
  sendEmail: tool({
    description: &quot;Send an email&quot;,
    inputSchema: z.object({
      to: z.string(),
      subject: z.string(),
      body: z.string()
    }),
    execute: async ({ to, subject, body }) =&gt; `Email sent to ${to}`
  })
};

// Create a secure execution sandbox
const executor = new DynamicWorkerExecutor({
  loader: env.LOADER
});

// Wrap them in code mode
const codemode = createCodeTool({ tools, executor });

// Pass them to your agent...</code></pre><p>And then the model writes something like:</p><pre><code class="language-ts">async () =&gt; {
  const weather = await codemode.getWeather({ location: &quot;London&quot; });
  if (weather.includes(&quot;sunny&quot;)) {
    await codemode.sendEmail({
      to: &quot;team@example.com&quot;,
      subject: &quot;Nice day!&quot;,
      body: `It&apos;s ${weather}`
    });
  }
  return { weather, notified: true };
};</code></pre><p>The model is a very appealing. If the tool surface is big enough, or the task requires a few dependent calls, this starts to look much nicer than repeatedly asking the model what the next tool call should be. <a href="https://www.youtube.com/watch?v=-ZikRWR1Gb4&amp;ref=huuhka.net">Cloudflare also has their own presentation on the topic here</a></p><h3 id="why-azure-devops-looked-like-a-perfect-test-case">Why Azure DevOps looked like a perfect test case</h3><p>Azure DevOps MCP is exactly the kind of thing that makes you start looking at alternatives to plain tool calling.</p><ul><li>the tool surface is big- many tools are adjacent or partially overlapping- descriptions and schemas add a lot of tokens</li><li>workflows often require multiple calls in sequence</li></ul><p>So on paper the fit looked great. I initially thought I could more or less just:</p><p>1.  put a <code>search</code> tool in front of the MCP surface<br>2. put an <code>execute</code> tool behind it<br>3. let the model search the Azure DevOps tools it needs<br>4. let it write one small program to do the actual work</p><p>That was the first version of this experiment. <a href="https://github.com/DrBushyTop/ado-codemode-mcp/tree/feat/mcp-wrap?ref=huuhka.net" rel="noreferrer">It is still preserved here for reference</a></p><p><a href="https://github.com/DrBushyTop/ado-codemode-mcp/tree/master?ref=huuhka.net">The current implementation is here</a>.</p><h3 id="the-first-lesson-this-was-not-nearly-as-plug-and-play-as-i-expected">The first lesson: this was not nearly as plug-and-play as I expected</h3><p>The basic wrapper part is easy. The hard part is getting good model behavior. That turns out to depend much more on the quality of the underlying tool contract than I first expected.</p><p>My main takeaway from the whole experiment is this:</p><blockquote>Code Mode works best when the model can plan data flow, not just function calls.</blockquote><p>That sounds obvious in hindsight, but it really changed how I think about wrapping MCP servers. If the model sees a list of tools and their input schemas, but it does not have a reliable idea of what those tools return, then longer chains get shaky very quickly. And once that happens, the model starts probing.</p><p>That probing shows up as extra search and execute calls, retries with slightly different arguments and fallback behavior outside the intended path. In practice it meant that my test agents started using az cli and reading the repo for clues.</p><p>At that point, a lot of the benefit of Code Mode starts to disappear, and the target I was chasing was something like 1 search call and 1-2 execute calls to accomplish what&apos;s needed.</p><h3 id="wrapping-the-mcp-was-the-wrong-abstraction-for-this-case">Wrapping the MCP was the wrong abstraction for this case</h3><p>This was the main practical problem.</p><p>Wrapping an MCP tool surface is not enough on its own if the wrapped tools do not expose what they actually return in a useful way. With Azure DevOps MCP, the model often had enough information to discover what to call next, but not enough information to confidently reason about what each call would return. That created a bad pattern:</p><ul><li>the model could find a tool</li><li>it could often call the tool correctly, but then it had to guess the output shape,  and that made multi-step orchestration inside a single execute call unreliable</li></ul><p>So instead of getting the elegant &#x201C;one search, one execute&#x201D; flow I was aiming for, I initially got a lot more churn than expected.</p><p>That is not really a knock on Code Mode itself. It is more a statement about the dependency chain underneath it. </p><blockquote>If the model is supposed to write a small program, it needs the same kind of confidence about function outputs that we as developers would want if we were wiring together a new client.</blockquote><h3 id="the-major-issues-i-ran-into">The major issues I ran into</h3><p><strong>The wrapped MCP surface did not expose enough output information</strong></p><p>This was the biggest one. The model could see inputs much more reliably than outputs. That meant it could discover and call tools, but not confidently build longer chains inside a single execute call.</p><p> This was the point where I started feeling that &#x201C;wrapping MCP servers with Code Mode&#x201D; may not be the best idea in general unless the wrapped surface also presents good return contracts.</p><p><strong>Search and execute shape matter a lot</strong></p><p>I went through a few iterations on this. If discovery leaks too much into execute, the model keeps rediscovering tools inside the program. If search returns too much, the model can get stuck doing search loops. If search returns too little, the model misses one supporting operation and has to search again.</p><p>This is one of those things that looks simple in a diagram and then turns out to be quite sensitive in practice. Practically just iterated on the search tool prompting and tried to steer the model with better examples on how to get the correct data.</p><p><strong>Secure execution is a real engineering problem outside Dynamic Workers</strong></p><p>If you use <a href="https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/?ref=huuhka.net" rel="noreferrer">Cloudflare&apos;s Dynamic Workers</a>, the sandboxing story is much cleaner.<br>If you do not, you need to think through how you are executing untrusted model-generated code. That was not impossible, but it definitely was not plug-and-play either.</p><p>I ended up building a local sandbox executor around container isolation,  <code>runsc</code> , a narrow callback surface and no general network egress from the sandbox. It was not that difficult in the end, but still needs you to run a docker / podman container which is not very suitable for non technical users. I&apos;ve been thinking of moving this to run on <a href="https://docs.azure.cn/en-us/aks/use-pod-sandboxing?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">Kata containers on AKS</a> in the future, but that adds another layer of complexity.</p><p>In practice, plenty of people are already effectively YOLO-running code through tools like OpenCode or Claude Code anyway, so the ecosystem clearly has a pretty large gap here. That makes this whole area fertile ground for credential leaks and other preventable issues. I don&apos;t think this is the main blocker to adoption, but I do think it is something to think about closely before taking code mode in use.</p><h3 id="the-solution-a-direct-rest-contract">The solution: a direct REST contract</h3><p>It became pretty clear that I was spending more and more effort compensating for the quality of the wrapped tool surface. That made me step back and ask a simpler question: Could I just work from the Azure DevOps REST contract directly?</p><p>It turns out the answer is <strong>yes</strong>.</p><p>Microsoft publishes the Azure DevOps REST specs in <a href="https://github.com/MicrosoftDocs/vsts-rest-api-specs?ref=huuhka.net">MicrosoftDocs/vsts-rest-api-specs</a>. They are split by area and version rather than shipped as one giant contract, but they are good enough to build a searchable operation catalog from. That changed the whole shape of the system.</p><p>Instead of wrapping Azure DevOps MCP and trying to enrich its tool metadata- trying to infer output behavior from MCP responses, I switched to <code>search</code> over a static Azure DevOps REST operation catalog and <code>execute</code> over one helper that calls Azure DevOps by <code>operationId</code></p><p>That ended up looking much more like the Cloudflare pattern, just on top of the Azure DevOps REST contract instead of an MCP surface.</p><p>The direct REST catalog version worked better for a simple reason: the model could see enough of the contract to actually reason through the chain. </p><p>It now had access to:</p><ul><li>operation IDs</li><li>path/query/body inputs</li><li>request body shape</li><li><strong>response schema</strong></li></ul><p>That was the missing piece. Once the output side of the contract became visible enough, the model behavior improved a lot.</p><p>The current implementation ended up with a pattern much closer to what I originally wanted. Far less fallback churn. It&apos;s still not perfect, but it is a completely different quality level from the original wrapped MCP version.</p><p>Example of a call:</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91036_Screenshot%202026-03-09%20at%2011.29.35.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="2292" height="1175"></figure><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/9932_Screenshot%202026-03-09%20at%2011.29.39.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="1652" height="259"></figure><h3 id="current-implementation-flow">Current implementation flow</h3><p>This is roughly what the current version does</p><figure class="kg-card kg-image-card kg-width-full kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91711_image.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="3887" height="1585"><figcaption><span style="white-space: pre-wrap;">Whole flow</span></figcaption></figure><figure class="kg-card kg-image-card kg-width-full kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91640_image.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="3655" height="400"><figcaption><span style="white-space: pre-wrap;">Search Path</span></figcaption></figure><figure class="kg-card kg-image-card kg-width-full kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91644_image.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="2977" height="585"><figcaption><span style="white-space: pre-wrap;">Execute Path</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/72225_image.png" class="kg-image" alt="Testing Cloudflare&apos;s Code Mode on Azure DevOps MCP" loading="lazy" width="1636" height="1362"><figcaption><span style="white-space: pre-wrap;">Trust Boundary</span></figcaption></figure><h3 id="conclusions">Conclusions</h3><p>I still think Code Mode is a strong idea. But it&apos;s clear that Cloudflare&apos;s own use case is just much more naturally aligned with it than &#x201C;wrap a random MCP server and hope the contract is good enough&#x201D;.  </p><ul><li>They control the API surface and tool contract</li><li>They can surface both inputs and outputs well</li><li>The contract is broad enough that code-based orchestration really pays off</li></ul><p>That is a very different situation from trying to wrap an existing third-party MCP server whose output side may be inconsistent or under-described.</p><blockquote>Code Mode is a very good fit for large, contract-rich tool surfaces. It is a much worse fit for tool surfaces where the model can call things but cannot confidently reason about what comes back.</blockquote><p>It is clearly part of a broader direction that Anthropic and OpenAI are moving toward as well.</p><p>For Azure DevOps specifically, I got much better results by moving one level lower and working from the REST contract directly instead of treating the existing MCP server as the final abstraction. That does not mean wrapping MCP is always wrong, it just means I would now ask a much stricter question before doing it:</p><p><strong>Does this tool surface tell the model enough about both what goes in and what comes out?</strong> If the answer is no, I would be cautious.</p><h3 id="repos-and-references">Repos and references</h3><ul><li><a href="https://www.github.com/DrBushyTop/ado-codemode-mcp/tree/master?ref=huuhka.net" rel="noreferrer">Current implementation</a> </li><li><a href="https://www.github.com/DrBushyTop/ado-codemode-mcp/tree/feat/mcp-wrap?ref=huuhka.net" rel="noreferrer">Earlier wrapped MCP experiment</a> </li><li><a href="https://www.github.com/cloudflare/agents/tree/main/packages/codemode?ref=huuhka.net" rel="noreferrer">Cloudflare&apos;s Code Mode Repo</a> </li><li><a href="https://www.youtube.com/watch?v=-ZikRWR1Gb4&amp;ref=huuhka.net" rel="noreferrer">Cloudflare&apos;s presentation</a></li><li><a href="https://developers.openai.com/api/docs/guides/tools-tool-search/?ref=huuhka.net" rel="noreferrer">OpenAI tool search</a></li><li><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool?ref=huuhka.net" rel="noreferrer">Claude tool search</a></li><li><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling?ref=huuhka.net" rel="noreferrer">Claude programmatic tool calling</a> </li><li><a href="https://www.anthropic.com/engineering/advanced-tool-use?ref=huuhka.net" rel="noreferrer">Anthropic advanced tool use post</a> </li></ul>]]></content:encoded></item><item><title><![CDATA[Enabling Custom (Bicep) Language Server support in OpenCode]]></title><description><![CDATA[I wanted Bicep diagnostics to show up in OpenCode with a custom LSP setup, as I noticed the models making a bunch of mistakes without it. Here's how to do it.]]></description><link>https://www.huuhka.net/enabling-bicep-language-server-support-in-opencode/</link><guid isPermaLink="false">69a43961dae91c00012b5c43</guid><category><![CDATA[AI]]></category><category><![CDATA[Azure]]></category><category><![CDATA[DevOps]]></category><category><![CDATA[OpenCode]]></category><category><![CDATA[Bicep]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Sun, 01 Mar 2026 13:29:36 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11328_img_bicep.png" medium="image"/><content:encoded><![CDATA[<img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11328_img_bicep.png" alt="Enabling Custom (Bicep) Language Server support in OpenCode"><p>I wanted Bicep diagnostics to show up in OpenCode with a custom LSP setup, as I noticed the models making a bunch of mistakes without it. <a href="https://opencode.ai/docs/lsp/?ref=huuhka.net" rel="noreferrer">OpenCode supports multiple LSP servers out of the box</a>, as well as configuring custom ones.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x2757;</div><div class="kg-callout-text">This requires <a href="https://github.com/anomalyco/opencode/pull/15570?ref=huuhka.net" rel="noreferrer">anomalyco/opencode#15570</a> to be merged before functioning correctly</div></div><h3 id="why-bother-with-lsps-in-opencode">Why bother with LSPs in OpenCode?</h3><p>The main benefit is fast feedback in the place you already work:</p><ul><li>Catch Bicep errors while editing, not during deployment.</li><li>Catch issues when the file is read/analyzed by tooling workflows in opencode.</li><li>Get proper diagnostics (for example BCP007) instead of waiting for az deployment to fail later.</li><li><a href="https://opencode.ai/docs/tools/?ref=huuhka.net#lsp-experimental" rel="noreferrer">OpenCode also supports experimental navigation tools through the LSP.</a></li></ul><p>In practice, this shifts mistakes left: less context switching, less failed pipeline runs, faster fixes.</p><p>So how do you actually get this running?</p><h2 id="option-1-install-bicep-language-server-manually">Option 1: Install Bicep Language Server manually</h2><p>If you want a stable path independent from editor updates, install the language server under your own folder (for example <code>~/.opencode-lsp/bicep-langserver</code>) and point OpenCode there.</p><pre><code class="language-pwsh"># Install script
function Install-BicepLangServer {
    param([Parameter(Mandatory = $true)][string]$DestinationPath)
    $releaseUrl = &apos;https://github.com/Azure/bicep/releases/latest/download/bicep-langserver.zip&apos;
    $tempZip = Join-Path ([System.IO.Path]::GetTempPath()) &apos;bicep-langserver.zip&apos;
    $dllPath = Join-Path $DestinationPath &apos;Bicep.LangServer.dll&apos;
    # Check if already installed
    if (Test-Path $dllPath) {
        $updateChoice = Read-Host &apos;Bicep Language Server already installed. Update? [y/N]&apos;
        if ($updateChoice.Trim().ToUpperInvariant() -ne &apos;Y&apos;) {
            Write-Info &apos;Skipping Bicep Language Server update.&apos;
            return $true
        }
    }
    Write-Info &apos;Downloading Bicep Language Server...&apos;
    try {
        Invoke-WebRequest -Uri $releaseUrl -OutFile $tempZip -UseBasicParsing
        if (Test-Path $DestinationPath) {
            Remove-Item -Recurse -Force $DestinationPath
        }
        New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null
        Expand-Archive -Path $tempZip -DestinationPath $DestinationPath -Force
        Remove-Item $tempZip -ErrorAction SilentlyContinue
        Write-Success &quot;Bicep Language Server installed to: $DestinationPath&quot;
        return $true
    }
    catch {
        Write-Warn &quot;Failed to install Bicep Language Server: $($_.Exception.Message)&quot;
        Remove-Item $tempZip -ErrorAction SilentlyContinue
        return $false
    }
}

# Adding LSP config to opencode config JSON
function Add-LspConfigToOpenCodeConfig {
    param(
        [Parameter(Mandatory = $true)][string]$ConfigJson,
        [Parameter(Mandatory = $true)][string]$LspBasePath,
        [Parameter(Mandatory = $true)][bool]$BicepInstalled,
        [Parameter(Mandatory = $true)][bool]$PsesInstalled
    )
    $configObj = $ConfigJson | ConvertFrom-Json
    # Use forward slashes for cross-platform compatibility in JSON
    $lspBasePathNormalized = $LspBasePath -replace &apos;\\&apos;, &apos;/&apos;
    $lspConfig = @{}
    if ($BicepInstalled) {
        $lspConfig[&apos;bicep&apos;] = @{
            command    = @(&apos;dotnet&apos;, &quot;$lspBasePathNormalized/bicep-langserver/Bicep.LangServer.dll&quot;)
            extensions = @(&apos;.bicep&apos;, &apos;.bicepparam&apos;)
        }
    }
    if ($PsesInstalled) {
        $psesStartScript = &quot;$lspBasePathNormalized/pses/PowerShellEditorServices/Start-EditorServices.ps1&quot;
        $psesModulesPath = &quot;$lspBasePathNormalized/pses&quot;
        $psesLogsPath = &quot;$lspBasePathNormalized/pses/logs&quot;
        $lspConfig[&apos;powershell&apos;] = @{
            command    = @(
                &apos;pwsh&apos;,
                &apos;-NoLogo&apos;,
                &apos;-NoProfile&apos;,
                &apos;-Command&apos;,
                &quot;&amp; &apos;$psesStartScript&apos; -Stdio -HostName OpenCode -HostVersion 1.0.0 -BundledModulesPath &apos;$psesModulesPath&apos; -LogPath &apos;$psesLogsPath&apos; -LogLevel Normal&quot;
            )
            extensions = @(&apos;.ps1&apos;)
        }
    }
    if ($lspConfig.Count -gt 0) {
        $configObj | Add-Member -NotePropertyName &apos;lsp&apos; -NotePropertyValue $lspConfig -Force
    }
    return ($configObj | ConvertTo-Json -Depth 10)
}</code></pre><p>The resulting config:</p><pre><code class="language-json">{
  $schema: https://opencode.ai/config.json,
  lsp: {
    bicep: {
      extensions: [
        .bicep,
        .bicepparam
      ],
      command: [
        dotnet,
        /Users/pasi/.opencode-lsp/bicep-langserver/Bicep.LangServer.dll
      ]
    }
  }
}</code></pre><h2 id="option-2-reuse-the-vs-code-extensions-language-server">Option 2: Reuse the VS Code extension&apos;s language server</h2><p>If you already have Bicep extension installed in VS Code/Insiders, you can point OpenCode to that DLL instead of installing another copy.</p><p>Example locations on macOS:</p><ul><li><code>/Users/pasi/.vscode-insiders/extensions/ms-azuretools.vscode-bicep-VERSION/bicepLanguageServer/Bicep.LangServer.dll</code></li><li><code>/Users/pasi/.vscode/extensions/ms-azuretools.vscode-bicep-VERSION/bicepLanguageServer/Bicep.LangServer.dll</code></li></ul><p>Helper script to resolve latest installed extension DLL</p><pre><code class="language-pwsh">function Get-VSCodeBicepLangServerPath {
    [CmdletBinding()]
    param(
        [ValidateSet(&apos;insiders&apos;, &apos;stable&apos;)]
        [string]$Channel = &apos;insiders&apos;
    )
    $home = $HOME
    $basePath = if ($Channel -eq &apos;insiders&apos;) {
        Join-Path $home &apos;.vscode-insiders/extensions&apos;
    } else {
        Join-Path $home &apos;.vscode/extensions&apos;
    }
    if (-not (Test-Path $basePath)) {
        return $null
    }
    $candidate = Get-ChildItem -Path $basePath -Directory -Filter &apos;ms-azuretools.vscode-bicep-*&apos; |
        Sort-Object Name -Descending |
        ForEach-Object {
            Join-Path $_.FullName &apos;bicepLanguageServer/Bicep.LangServer.dll&apos;
        } |
        Where-Object { Test-Path $_ } |
        Select-Object -First 1
    return $candidate
}</code></pre><p>Then use that resolved path in the same lsp.bicep.command array.</p><h3 id="the-caveat-with-vs-code-paths">The caveat with VS Code paths</h3><p>The extension folder has a version in its name, so the path changes when the extension updates. That means one of:</p><ul><li>update opencode.json after extension updates,</li><li>script the path resolution and regenerate config, maybe updating via opencode plugin?</li><li>or keep the manual install path for stability.</li></ul><p>For quick setup, VS Code reuse is convenient. For long-term predictability, manual install usually wins. I&apos;ve been sticking with the manual version for now, and updating it now and then myself.</p><h2 id="quick-verification">Quick verification</h2><p>After setting config, run LSP diagnostics against an invalid .bicep file and verify you get Bicep diagnostic codes (for example BCP007).<br>That confirms both the language-ID mapping and language server wiring are working.</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91042_556576696-d205b403-c0b5-4678-afa3-366927a3d11e.png" class="kg-image" alt="Enabling Custom (Bicep) Language Server support in OpenCode" loading="lazy" width="2662" height="704"></figure><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/91042_556578445-a4b27817-336e-484d-be6b-e6097a8552b7.png" class="kg-image" alt="Enabling Custom (Bicep) Language Server support in OpenCode" loading="lazy" width="2064" height="712"></figure>]]></content:encoded></item><item><title><![CDATA[How I currently develop with LLM models (Early 2026)]]></title><description><![CDATA[I've been experimenting with a lot of different agent setups, harnesses and model combinations over the last months, and I've ended up settling on a workflow that is fairly simple in structure even if the tooling around it is changing quickly.]]></description><link>https://www.huuhka.net/how-i-currently-develop-with-llm-models-early-2026/</link><guid isPermaLink="false">69add8d226d9b80001669784</guid><category><![CDATA[Developer Tools]]></category><category><![CDATA[AI]]></category><category><![CDATA[GitHub Copilot]]></category><category><![CDATA[OpenCode]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Wed, 25 Feb 2026 17:15:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/82028_sessions.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Agentic Dev theme:<br>- <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/" rel="noreferrer">A mental model for LLM tooling primitives</a><br>- <a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">Research - Plan - Implement</a><br>- <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/" rel="noreferrer">Primary vs Subagents in LLM harnesses</a><br>- <a href="https://www.huuhka.net/how-i-currently-develop-with-llm-models-early-2026/" rel="noreferrer">How I currently develop with LLM models (Early 2026)</a> <br>- <a href="https://www.huuhka.net/building-your-own-pr-reviewer-with-coding-agents/" rel="noreferrer">Building your own PR reviewer with coding agents</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/82028_sessions.png" alt="How I currently develop with LLM models (Early 2026)"><p>I&apos;ve been experimenting with a lot of different agent setups, harnesses and model combinations over the last months, and I&apos;ve ended up settling on a workflow that is fairly simple in structure even if the tooling around it is changing quickly.</p><p>This post is not really meant as a &quot;this is the correct way&quot; type of thing. It&apos;s just the current version of what has worked best for me in actual development work.</p><p>It also builds on a few earlier posts of mine, especially <a href="https://www.huuhka.net/research-plan-implement/">Research - Plan - Implement</a>, <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/">Primary vs Subagents in LLM harnesses</a> and <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/">A mental model for LLM tooling primitives</a>.</p><p><strong>The short version</strong></p><ul><li>I have centralized most of my LLM-assisted development into <a href="https://opencode.ai/?ref=huuhka.net">OpenCode</a>, though all the other stuff does work on any harness, like Github Copilot.</li><li>I still mostly work with the Research - Plan - Implement pattern, but I scale the ceremony up and down depending on the task.</li><li>In practice, plan + implement is often enough.- I read the plans carefully and go back and forth with the planning agent until the open questions are actually resolved.</li><li>I tested looped implementation with Ralph loops, and it does work, but it hasn&apos;t really become my default.</li><li>For larger tasks, I now prefer splitting work into parallel streams and handing those to orchestrators.</li><li>I&apos;m mostly using GPT-5.4, with some Opus 4.6 mixed in where it helps.</li></ul><h3 id="standardizing-on-opencode-as-the-main-harness">Standardizing on OpenCode as the main harness</h3><p>The main thing I wanted was standardization.</p><p>At this point I have models available from multiple places: customer environments, GitHub Copilot, and my own subscriptions. I don&apos;t really want my workflow to completely change based on where the model happens to live. OpenCode has worked well for me here because I can keep the same harness, the same commands, the same agents and roughly the same habits while switching the model underneath.</p><p>Sure, you can argue that the provider&apos;s own harness is always going to be the ideal place to use their model. In some very specific cases that may well be true. But for my day-to-day work, the convenience of having one interface and one working style has been more valuable than whatever marginal gains I might get by constantly moving between native tools. </p><p>And if I ever want to replace my UI in the future, I can still do that without having to change the whole workflow as OpenCode is built on a Client-Server model. I&apos;m still mostly on the normal OpenCode TUI though, and testing the new-ish desktop client here and there.</p><p>For me, that standardization matters more in my daily work than chasing theoretical best-case setups.</p><h3 id="rpi-is-still-the-backbone">RPI is still the backbone</h3><p>I wrote earlier about <a href="https://www.huuhka.net/research-plan-implement/">Research - Plan - Implement</a>, and that is still basically the backbone of how I work.</p><p>What has changed a bit is mostly how often I use the full ceremony.</p><ul><li>For small tasks, I often just talk directly with the coding agent.</li><li>For medium tasks, plan + implement is usually enough.</li><li>For larger, messier or riskier work, I still want the full research -&gt; plan -&gt; implement flow.</li></ul><p>The important part for me is not the ritual itself. The point is to keep context under control and reduce ambiguity before implementation starts.</p><p>If a task does not need three separate phases, I don&apos;t force it. That flexibility and simplicity are the reasons why the pattern has kept working for me.</p><p>I&apos;m still not in agreement with myself on whether the plan / research artifacts should be committed in the repo or not. Often it feels like the codebase moves so fast that old plans and research files get stale pretty quickly, and that the main value of those files is in the moment when they are created and discussed, not as a long-term reference. But on the other hand, having a record of the thinking process can sometimes be useful for future reference or for other team members. </p><h3 id="the-plan-is-where-i-do-most-of-the-thinking">The plan is where I do most of the thinking</h3><p>One thing I don&apos;t do is generate a plan and then treat it as a decorative artifact.<br>I read the plans. I discuss them with the planning agent. I answer the open questions. If I notice missing angles, I ask it to expand the plan. If something looks too handwavy, I push it to be more concrete.</p><p>This is probably the cheapest point in the whole process to catch misunderstandings, and there have been numerous cases where reading the plan actually makes me change the initial approach I had in mind completely, or I understand that some very important feature or edge case is missing from my mental model. </p><p>Once implementation starts, every missing assumption gets more expensive. So I would much rather spend the extra few minutes in the planning stage than later do a half-implementation, realize the feature shape was wrong, and start steering it back.</p><p>In that sense, the plan is not just a handoff file. It&apos;s also the point where I clarify the task for myself and the team I&apos;m working with.</p><h3 id="i-tested-looping-implementations-with-ralph-loops">I tested looping implementations with Ralph loops</h3><p>I have also spent some time testing a more loop-oriented way of working. By that I mean taking the plan and then repeatedly running implementation steps in a loop, or even having the loop continue until some completion promise is hit. This did actually work reasonably well. (Often there was no promise, just a list of tasks and YOLO)</p><p><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/scripts/ralph-loop.sh?ref=huuhka.net">My script</a> for this was intentionally very simple:</p><pre><code class="language-bash">ralph-loop.sh PROMPT.md --agent Implementer --max 30</code></pre><p>That simplicity is also part of the appeal. You don&apos;t necessarily need a huge framework around the idea to test if it fits your workflow.</p><p>That said, I don&apos;t have an infinite request budget, so this has not really felt like the best default for me. When I was testing this more actively, Opus 4.5 and Opus 4.6 were also only available to me through GitHub Copilot&apos;s smaller context window, which meant compaction happened fairly quickly even with the loop. At that point the whole thing starts to feel a bit less attractive.</p><p>So my current view is not that looping is bad. More that if you have the budget and the patience to optimize around it, it is definitely worth exploring. I just haven&apos;t felt that simple looping, by itself, is where I get the best tradeoff.</p><p>What changed for me here is that GPT-5.4&apos;s own compaction has generally felt good enough for the type of work I do. OpenCode&apos;s own compaction has also felt solid. Of course, native compaction is a bit of a black box. You don&apos;t really know in exact terms what the model decided to compress and how. But in practical terms, the result has been good enough often enough that I no longer feel a big need to build a loop around everything. If the default context management is already holding up, I would rather keep the workflow simpler.</p><h3 id="for-bigger-work-i-split-plans-into-parallel-streams-instead">For bigger work, I split plans into parallel streams instead</h3><p>The bigger change in my own workflow has been here.</p><p>Instead of trying to keep one long implementation thread running forever, I now often ask the planning agent to split the implementation into multiple workstreams that could be handled in parallel. This has worked surprisingly well, even in larger codebases.</p><p>The reason I like this is fairly close to what I wrote earlier in <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/">Primary vs Subagents in LLM harnesses</a>. If a split actually reduces context pressure and gives you cleaner handoffs, it&apos;s useful. If it doesn&apos;t, then it&apos;s mostly just ceremony.</p><p>The plan file becomes a real handoff artifact here. It tells each stream what it is responsible for, what is already known, and what done should look like.<br>A simplified example could look something like this:</p><pre><code class="language-md">## Parallel Streams (very rough example)

### Stream A - API contract changes

- [ ] Add endpoint contract
- [ ] Add validation and tests

### Stream B - UI flow changes

- [ ] Add new settings UI
- [ ] Add loading and error states

### Stream C - Verification

- [ ] Add integration coverage
- [ ] Run browser validation</code></pre><p>That kind of structure has been much more useful for me than trying to just keep one giant thread alive for as long as possible.</p><p>Once I have the workstreams, I build an orchestrator instructions file for the task.<br>This file contains the operational rules for the orchestrator: how it should delegate work to subagents, how it should update the plan, how it should verify the implementation, how it should test, and what kind of review it should do before calling the work complete. Then I start one orchestrator per workstream and let them go.</p><p>Here&apos;s an example of orchestrator instructions</p><pre><code class="language-md">## Orchestrator Instructions

These instructions are for the orchestrator agent coordinating the work.

- Tooling &amp; verification
  - Use `bun` for all installs, builds and tests.
  - A dev server is ALREADY RUNNING. DO NOT START NEW SERVERS unless it has crashed.
  - Use the Chrome DevTools MCP for all browser-based verification.
  - When using Chrome DevTools MCP screenshots:
    - Save each screenshot file into the `screenshots/` folder at repo root.
    - Only after saving, read or reference the screenshot file as needed.
  - The orchestrator must be the only agent using the MCP server (subagents should not talk to it directly).

- Workflow &amp; delegation
  - The orchestrator is responsible for verifying each change end-to-end (tests, manual checks via Chrome MCP, quick code review).
  - The orchestrator should not write application code directly.
    - Delegate implementation work to `subagents/code/coder-agent`.
  - For each implementation task, instruct the coder subagent to:
    - Read the implementation plan to understand the whole workstream (link the file to them)
    - First use `subagents/research/codebase-locator` and `subagents/research/codebase-analyzer` to find entry points and understand patterns.
    - If the work is UI related, use their frontend design skill
    - Only then implement changes.

- Tasklist maintenance
  - Every task in the tasklist is a checklist item (`- [ ]`).
  - For each task the orchestrator completes, they must:
    - Update the tasklist by checking off the corresponding item (`- [x]`).
    - Optionally record a short note or the commit hash next to the checked item.
  - Updating the tasklist is part of the task and should be included in the same commit as the implementation or an immediate follow-up commit.

- Commits &amp; granularity
  - After each task is implemented and verified, the orchestrator must create a separate git commit for that task.
  - Do not batch multiple tasks into a single commit unless a task explicitly says it depends on another and they cannot be separated.
  - Commit messages should mention the task id.
  - DO NOT REVERT any unrelated code changes</code></pre><p>Starting the flow is then just:</p><pre><code class="language-md">You are the orchestrator for Stream A.
Read @orchestrator-instructions.md
Read @implementation-plan.md
Start / Continue the work.</code></pre><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/82027_image.png" class="kg-image" alt="How I currently develop with LLM models (Early 2026)" loading="lazy" width="1594" height="1062"><figcaption><span style="white-space: pre-wrap;">Orchestrators at work</span></figcaption></figure><p>In this setup, I don&apos;t really think of the orchestrator as &quot;another coding agent&quot;. I think of it more as an execution manager and verifier. The actual implementation can be delegated downwards, while the orchestrator keeps track of the plan state, runs checks, and makes sure the result still matches the original intent.</p><p>After that, I still like doing an additional review-oriented pass just to verify that the work really is implemented, and not just described confidently. This might also be a good point to do some extra manual checks for the actual implementation, but also validate your own mental model of the functionality in the codebase so you can confidently build on top of it in future work.</p><h3 id="browser-verification-is-becoming-part-of-the-normal-loop">Browser verification is becoming part of the normal loop</h3><p>Another thing that has slowly become more important in my workflow is browser-side verification.</p><p>I&apos;ve written about <a href="https://www.huuhka.net/browser-verification-for-coding-agents-chrome-devtools-mcp-vs-agent-browser/">Chrome DevTools and agent browser style tooling</a>, so I won&apos;t go too deep into those here, but the short version is that I increasingly want verification to include more than just tests and static review. Most of the time, the orchestrator is responsible for that too.</p><p>So in addition to delegating implementation and running tests, I also want it to verify the result in the browser where relevant. That can mean Chrome DevTools, and increasingly it can also mean agent browser style tooling.</p><p>I&apos;m still incorporating agent browser more fully into the toolset, but I think the longer-term benefit there is pretty clear: individual subagents should eventually be able to verify their own work in parallel as part of the implementation flow.  Chrome DevTools style MCP setups have felt a bit less happy when multiple agents try to use them at once, so for now that verification often sits more naturally at the orchestrator layer.</p><h3 id="clear-validation-matters-more-than-the-harness">Clear validation matters more than the harness</h3><p>One thing that feels very obvious to me at this point is that regardless of which harness you use, the best results tend to come from giving the model a clear way to validate whether the result is actually correct.That can be tests, visual checks, browser flows, snapshots, linting, typechecking, golden outputs, or any other concrete signal that tells the model when it has matched the target and when it has not.</p><p>Without that feedback loop, you are much more dependent on the model confidently approximating what you meant. Sometimes that is enough, but often it isn&apos;t.</p><p>This is also why it makes sense that people copy test sets from existing applications when trying to clone them outright. The tests are not just verification, they are also an unusually precise description of expected behavior. If you can give the model that kind of target, the odds of getting the exact result you wanted go up quite a bit. So while I do care about harnesses and agent structure a lot, I would still rank clear validation above most harness-level differences.</p><h3 id="model-mix-mostly-gpt-54-some-opus-46">Model mix: mostly GPT-5.4, some Opus 4.6</h3><p>At the model level, I&apos;m mostly using GPT-5.4 right now, with some Opus 4.6 added in. The biggest reason is pretty simple: the larger context window of GPT-5.4 is very useful in this kind of work, at least compared to what I currently get from Opus through GitHub Copilot (128k max).</p><p>Opus on the other hand is very good especially for UI work, particularly when combined with a stronger design-oriented skill or prompt setup like <a href="https://github.com/anthropics/skills/blob/main/skills/frontend-design/SKILL.md?ref=huuhka.net">Anthropic&apos;s frontend-design skill</a>. I&apos;ve also meant to test out <a href="https://github.com/cyxzdev/Uncodixfy?ref=huuhka.net">Uncodixfy skill</a> to see if it can help either of these models give better UI outputs, but that&apos;s still on the to-do list.</p><p>For everything else, GPT-5.4 and GPT-5.3-Codex have handled the work very well.<br>One thing that does feel fairly obvious, though, is that both models have a set number of UI templates they tend to lean on whenever you ask them to build something from scratch. That is not really a surprise anymore, but it is visible. So even when the model is good, the prompting and skill layer still matter a lot if you want the result to feel intentional instead of generic. Ask a model to build 10 different takes on the same UI component, and you&apos;ll see the same few templates come up again and again. That is not necessarily a problem, but it is something to be aware of when you&apos;re trying to get a specific design or interaction pattern out of the model.</p><h2 id="final-thoughts">Final thoughts</h2><p>I don&apos;t really expect this to be my workflow a year from now. Right now though, this is the setup that has felt the most useful in actual development work:</p><ul><li>standardize the harness where possible</li><li>add process only when the task complexity justifies it- use plans as real handoff artifacts instead of generated paperwork</li><li>parallelize bigger work instead of stretching one implementation thread forever</li><li>verify in the browser too, not just in code and tests</li></ul><p>That&apos;s basically it.</p><p>The models will keep changing, the harnesses will keep changing, and some of these tradeoffs will likely look different again fairly soon. But for now, this has been a good local optimum for me.<br></p>]]></content:encoded></item><item><title><![CDATA[Designing a shared OpenTelemetry contract for AI services on Azure]]></title><description><![CDATA[In this post I'll walk through how I went about  making multiple services behave like they belong to the same platform from an OpenTelemetry perspective.]]></description><link>https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/</link><guid isPermaLink="false">69de736ab92fba00013b61e0</guid><category><![CDATA[AI]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Azure]]></category><category><![CDATA[Telemetry]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Sun, 22 Feb 2026 18:13:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141717_lightsaber-collection-hqBr-KfgR8o-unsplash.jpg" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is a part of a larger AI Dev Platform theme:<br>- <a href="https://www.huuhka.net/ai-dev-platform-fundamentals/" rel="noreferrer">Azure AI Dev Platform Fundamentals</a><br>- <a href="https://www.huuhka.net/practical-experiences-with-azure-apim-ai-gateway-and-imported-foundry-endpoints/" rel="noreferrer">Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a><br>- <a href="https://www.huuhka.net/connecting-opencode-with-microsoft-foundry-models/" rel="noreferrer">Connecting OpenCode with Microsoft Foundry Models</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141717_lightsaber-collection-hqBr-KfgR8o-unsplash.jpg" alt="Designing a shared OpenTelemetry contract for AI services on Azure"><p>Once you have more than one AI-facing service behind the same Azure API Management layer, telemetry starts drifting almost immediately.</p><p>One service calls the tool identifier one thing. Another uses a different header name. A third one emits a metric dimension that looked harmless until somebody tried to chart it and discovered the cardinality was terrible. At that point you can still say you have observability, but the useful part of it starts slipping away.</p><p>In this post I&apos;ll walk through how I went about solving this issue. Not how to turn on OpenTelemetry, but instead how to make multiple services behave like they belong to the same platform.</p><h3 id="the-problem-i-cared-about">The problem I cared about</h3><p>What I wanted was fairly simple. If traffic entered through one platform edge, I wanted the downstream services to agree on what a request was, how it should be attributed, and which parts of that attribution were safe to put on metrics.</p><p>That sounds boring, but for AI workloads it matters quite a lot. I usually want to answer questions like which tool was actually used, which client path generated the traffic, whether a specific agent integration is noisy, and where the cost is going. If every backend answers those in a slightly different way, the dashboards stop being trustworthy surprisingly fast.</p><p>The other awkward part was that the services weren&apos;t all in the same stack. Some were .NET, some were TypeScript, and I really didn&apos;t want both ecosystems inventing their own baggage parsing and metric filtering conventions. So I ended up treating telemetry as a platform contract instead of a helper library.</p><h3 id="a-contract-not-just-shared-code">A contract, not just shared code</h3><p>The main design decision was to move the shared behavior into one contract file and then have both the .NET and TypeScript libraries implement that contract.</p><p>This split meant the important decisions lived in one place: which <code>myprefix.*</code> baggage keys exist, what the resolution order is, which metric instruments are expected, and which attributes are explicitly forbidden from metrics.</p><p>The common layer itself was just a YAML file. In simplified form, it looked like this:</p><pre><code class="language-yaml">version: 1

aiplat:
  baggage:
    keys:
      - myprefix.request_id
      - myprefix.tool_id
      - myprefix.user_id_hash
      - myprefix.opencode_agent_name
    headerFallbacks: {}
    resolutionOrder:
      useOtelBaggage: true
      useRawBaggageHeader: true
      useHeaderFallbacks: false

  metrics:
    meterName: AIPlatform.AiPlat
    instruments:
      - name: myprefix_requests_total
        type: counter
        unit: &quot;1&quot;
        attributes:
          - myprefix.tool_id
          - myprefix.opencode_agent_name
          - http.method
          - http.status_code
          - http.route
    forbiddenMetricAttributes:
      - myprefix.user_id_hash</code></pre><p>I like this shape because you can understand most of the platform opinion just by looking at the file. The user hash exists as a shared attribute, but it&apos;s explicitly forbidden from metrics. Header fallbacks exist as a mechanism, but the normal path keeps them turned off.</p><p>That last part was one of the main reasons I wanted a contract at all. Telemetry on AI systems has a bad habit of turning into a junk drawer. Somebody adds a user hash to spans, somebody else thinks it&apos;d be nice on a metric, and three weeks later you&apos;re cleaning up a cardinality mess you could&apos;ve avoided by just being stricter in the first place. I hit this early on, and it was clear I needed to be more intentional about what goes on metrics and what doesn&apos;t.</p><p>With a contract file, the rules become pretty boring &#x2014; in a good way. If a field is in the shared config and marked metric-safe, both languages treat it as metric-safe. If header fallbacks are disabled there, they&apos;re disabled everywhere. If a key is forbidden from metrics, that&apos;s not a code review opinion anymore. It&apos;s just the rule.</p><h3 id="let-the-edge-do-the-normalization">Let the edge do the normalization</h3><p>The other thing I felt strongly about was ownership. I didn&apos;t want every backend to understand every client-specific header shape forever. That&apos;s exactly the kind of decision that feels harmless at the beginning and then quietly turns into coupling. So the rule became that API Management owns the edge normalization. Client-specific headers come in, APIM turns them into the platform-owned shape, appends the values into W3C baggage, and the services only need to understand the normalized platform contract.</p><p>It sounds obvious written out like that, but I think it&apos;s easy to get wrong. If both APIM and the services can independently decide how a platform attribute is sourced, the whole thing gets muddy very quickly. Some values come from baggage, some from raw headers, some from fallbacks, and eventually nobody&apos;s fully sure which layer is authoritative.</p><p>That&apos;s why I kept header fallbacks disabled by default. The shared libraries support them, but I think the healthier default is to force the edge to do the propagation properly.</p><p>In practice, I wanted this and not five different half-overlapping variants of it:</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141720_image.png" class="kg-image" alt="Designing a shared OpenTelemetry contract for AI services on Azure" loading="lazy" width="2590" height="220"></figure><h3 id="metric-cardinality">Metric cardinality</h3><p>The useful part of the design wasn&apos;t the config file by itself. It was what the config file made harder to mess up.</p><p>On spans I&apos;m fairly relaxed. If a piece of context is useful for debugging and it&apos;s handled safely, I don&apos;t mind carrying a decent amount of it. On metrics I&apos;m much more conservative.</p><p>The shared allowlist and forbidden-attribute model helped a lot here. Tool identifiers, route templates, method, status code, and a bounded operation name are all reasonable candidates. A user hash isn&apos;t. Request IDs aren&apos;t. Session IDs aren&apos;t. Those are good diagnostic attributes and terrible metric dimensions.</p><p>This split was especially important because some of the AI-specific context only exists inside the app. If the service parses MCP-style JSON-RPC payloads, for example, it can often derive a stable operation name or tool name that&apos;s genuinely useful on request metrics. That enrichment belongs in the app because the app actually understands the payload. Client lineage and normalized request identity, on the other hand, are edge concerns and belong in APIM.</p><h3 id="cross-language">Cross-language</h3><p>I think this would&apos;ve been much less useful if it only solved the .NET side nicely.<br>The .NET version is naturally a little heavier. It plugs into dependency injection, middleware, and the normal OpenTelemetry setup in ASP.NET Core. The TypeScript side is lighter and more wrapper-shaped. That&apos;s fine &#x2014; they don&apos;t need to look the same internally.</p><p>I wanted them to share the exact same config source, though. This allowed me to change a single location and have it flow to all of the services in the platform, regardless of language. I just had to make sure that no matter which language we were using, the config files were pulled in with the builds accordingly.</p><p>The shared library is code, but the contract it implements is still data. I didn&apos;t want the values duplicated into two language implementations at build time in some opaque way. I wanted both runtimes to load the same file and validate it normally.</p><p>They needed to behave the same way regardless of language. If APIM wrote a platform baggage key, both stacks needed to resolve it in the same order. If a metric attribute was safe in one service, it needed to be safe in the other. If a forbidden attribute was dropped from metrics in TypeScript but leaked through in .NET, the whole shared contract idea would&apos;ve been kind of pointless.</p><p>I think shared contract tests matter more than shared implementation details in setups like this. The point isn&apos;t that both libraries use the same code shape. The point is that they produce the same platform behavior.</p><h3 id="the-azure-perspective">The Azure perspective</h3><p>The implementation itself lived mostly in shared code and config, but we of course need some extra Azure parts to make this all run.</p><p>There was a central telemetry setup around Log Analytics, Application Insights, and a shared collector story when needed. API Management handled the practical edge work: trace continuation, normalized headers, baggage propagation, and gateway-side dimensions for the traffic that needed to be observable already at that layer.</p><p>If you already have a platform edge, that&apos;s where this kind of cross-cutting normalization belongs.</p><p>It also meant the services stayed smaller. They didn&apos;t need to know how a specific client integration decided to represent a parent session or a tooling version header. They just needed to consume the platform contract consistently.</p><h3 id="closing-thoughts">Closing thoughts</h3><p>Looking back, this was a simple-ish contract design task.</p><p>The edge normalizes and propagates. The shared config owns the rules. The language libraries implement those rules. Metrics stay intentionally boring. Richer context lives on spans unless there&apos;s a very good reason to promote it.</p><p>If I were doing this again, I&apos;d keep the same basics. The declarative contract file was the biggest win. Edge-owned normalization was the right split. And being strict on metric safety saved me from the kind of telemetry mess that&apos;s easy to create and annoying to clean up. However, I&apos;d likely implement this right from the start instead of waiting for the mess to happen first. The refactor was pretty easy, but it&apos;s still better to avoid the mess in the first place.</p>]]></content:encoded></item><item><title><![CDATA[Semantic Kernel to Microsoft Agent Framework: Practical reflections]]></title><description><![CDATA[Now that MAF is nearing a GA release, I took my existing demo and translated it to see how the new framework feels in real code.
In this post I'll recap my thoughts on the new framework.]]></description><link>https://www.huuhka.net/semantic-kernel-to-microsoft-agent-framework-practical-reflections/</link><guid isPermaLink="false">69a870cc26d9b80001669574</guid><category><![CDATA[AI]]></category><category><![CDATA[Azure]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Fri, 20 Feb 2026 18:08:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/4188_AgentFramework-1.png" medium="image"/><content:encoded><![CDATA[<img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/4188_AgentFramework-1.png" alt="Semantic Kernel to Microsoft Agent Framework: Practical reflections"><p>At <a href="https://globalai.community/chapters/helsinki/events/agentcon-2025-helsinki/?ref=huuhka.net" rel="noreferrer">AgentCon 2025 Helsinki</a>, I presented my multi-agent demo using Semantic Kernel orchestration.</p><p>On the same day, Microsoft announced Microsoft Agent Framework (MAF), so if I wanted to ever have the same presentation again I had to translate it to MAF instead. Now that it&apos;s nearing a GA release, I took my existing demo and translated it to see how the new framework feels in real code. </p><p>In this post I&apos;ll recap my thoughts on the new framework after taking it out for a spin.</p><p>The results can be found in these branches of the repo. I used version <code>1.0.0-rc1</code>.</p><ul><li><a href="https://github.com/DrBushyTop/MultiAgentSemanticKernel/tree/feat/agentFramework?ref=huuhka.net" rel="noreferrer">Demo repo (MAF branch)</a></li><li><a href="https://github.com/DrBushyTop/MultiAgentSemanticKernel/tree/semantickernel?ref=huuhka.net" rel="noreferrer">Demo repo (SK branch)</a></li></ul><p>Let&apos;s get going!</p><h3 id="agent-creation-less-plumbing-more-intent">Agent creation: less plumbing, more intent</h3><p>In SK, my agent setup centers around <code>Kernel</code> composition and per-agent kernel wiring. In MAF, it is mostly chat client + instructions + tools.</p><p><strong>Before (SK):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Runtime/AgentUtils.cs#L26-L70
var builder = Kernel.CreateBuilder();
builder.Services.AddSingleton&lt;IChatCompletionService&gt;(chatService);
builder.Services.AddSingleton&lt;IFunctionInvocationFilter, ConsoleFunctionInvocationFilter&gt;();

var agentKernel = builder.Build();

return new ChatCompletionAgent
{
    Name = name,
    Instructions = instructions,
    Kernel = agentKernel,
    Arguments = args,
};</code></pre><p><strong>After (MAF):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runtime/AgentFactory.cs#L15-L24
return new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = name,
    ChatOptions = new ChatOptions
    {
        Instructions = instructions,
        Temperature = temperature,
        Tools = tools ?? []
    }
});</code></pre><p>This was the first thing I noticed: less ceremony, easier to read, easier to explain. I did like the previous kernel composition model, even though it was a bit more difficult to understand at first, but I&apos;m sure this new model will grow on me as I get used to it.</p><p>Microsoft has these relevant <strong>docs</strong> in case you want to do the same migration:</p><ul><li><a href="https://learn.microsoft.com/agent-framework/overview/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">MAF overview</a></li><li><a href="https://learn.microsoft.com/agent-framework/migration-guide/from-semantic-kernel/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">SK -&gt; MAF migration guide</a></li></ul><h3 id="tool-registration-plugin-modelplain-function-tools">Tool registration: plugin model -&gt; plain function tools</h3><p>In SK, you had to use plugin classes + <code>[KernelFunction]</code> and plugin import. Now in MAF, it&apos;s possible to just pass methods directly with <code>AIFunctionFactory.Create(...)</code>.</p><p><strong>Before (SK):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Plugins/DevWorkflowPlugin.cs#L9-L30
public sealed class DevWorkflowPlugin
{
    [KernelFunction, Description(&quot;Generate OpenAPI from story and AC&quot;)]
    public string Oas_Generate(string story, string acceptance) =&gt; ...;
}

// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Runners/SequentialRunner.cs#L22-L36
kernel.ImportPluginFromType&lt;DevWorkflowPlugin&gt;();</code></pre><p><strong>After (MAF):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runners/SequentialRunner.cs#L21-L28
var tools = new List&lt;AITool&gt;
{
    AIFunctionFactory.Create(DevWorkflowTools.OasGenerate),
    AIFunctionFactory.Create(DevWorkflowTools.RepoCreateBranch),
    AIFunctionFactory.Create(DevWorkflowTools.CreateScaffold),
};</code></pre><p>This feels much more natural to me in C#: any function can become a tool without extra plugin lifecycle overhead.</p><p><strong>Docs:</strong></p><ul><li><a href="https://learn.microsoft.com/agent-framework/agents/tools/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">MAF tools</a></li><li><a href="https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.aifunctionfactory.create?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer"><code>AiFunctionFactory</code> API</a></li></ul><h3 id="workflow-runtime-model-orchestration-runtimeevent-stream">Workflow runtime model: orchestration runtime -&gt; event stream</h3><p>While SK gives the <code>InvokeAsync(...)</code> + <code>GetValueAsync(...)</code> style orchestration, MAF opts for a workflow event stream instead. With SK the result was always a bit different depending on the orchestration pattern, but with MAF I ended up with a more consistent pattern: we always return the list of assistant messages as the final output, and stream intermediate events during execution. I think this makes it easier to understand what is going on during execution, and gives more flexibility on how to handle intermediate events (e.g. tool calls) if needed.</p><p><strong>Before (SK):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Runners/ConcurrentRunner.cs#L89-L99
var orchestration = new ConcurrentOrchestration(diffAnalyst, testImpactor, secLint, compliance)
{
    ResponseCallback = AgentResponseCallbacks.Create(cli),
};

var runtime = new InProcessRuntime();
await runtime.StartAsync();

var result = await orchestration.InvokeAsync(prompt, runtime);
var output = await result.GetValueAsync(TimeSpan.FromSeconds(120));
await runtime.RunUntilIdleAsync();</code></pre><p><strong>After (MAF):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runtime/WorkflowRunner.cs#L35-L43
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, messages, cancellationToken: cancellationToken);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));

await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken))
{
    switch (evt)
    {
        case AgentResponseUpdateEvent e:
            // stream token and tool activity
            break;
        case WorkflowOutputEvent output:
            return output.As&lt;List&lt;ChatMessage&gt;&gt;() ?? [];
    }
}</code></pre><p>One note here was that the <code>AgentResponseUpdateEvent</code> returns on each streamed token individually, so you might have to do some concatenation there.</p><p><strong>Docs:</strong></p><ul><li><a href="https://learn.microsoft.com/agent-framework/workflows/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">MAF workflows</a></li><li><a href="https://learn.microsoft.com/semantic-kernel/frameworks/agent/agent-orchestration/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net" rel="noreferrer">SK orchestration patterns</a></li></ul><h3 id="filters-vs-middleware">Filters vs middleware</h3><p>SK uses filter interfaces. In my current MAF demo, I have not yet wired dedicated middleware in the main app - I currently intercept behavior in the workflow event loop. So basically the need for middleware for my use case here (plainly logging when tools are being called) was gone.</p><p><strong>SK filter registration + filter implementation:</strong></p><pre><code class="language-csharp">// Registration:
// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Program.cs#L59-L61
kernelBuilder.Services.AddSingleton&lt;IFunctionInvocationFilter, ConsoleFunctionInvocationFilter&gt;();

// Filter:
// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Runtime/ConsoleFunctionInvocationFilter.cs#L8-L31
public sealed class ConsoleFunctionInvocationFilter : IFunctionInvocationFilter
{
    public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func&lt;FunctionInvocationContext, Task&gt; next)
    {
        var functionName = context.Function.Name;
        var pluginName = context.Function.PluginName;
        var caller = _id?.Name ?? &quot;Agent&quot;;

        _cli.ToolStart(caller, pluginName ?? &quot;&quot;, functionName);
        _log?.LogInformation(&quot;&#x1F527; {Plugin}.{Func} by {Agent}&quot;, pluginName, functionName, caller);

        await next(context);
    }
}</code></pre><p><strong>MAF equivalent in this app: event-based interception in the workflow runner:</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runtime/WorkflowRunner.cs#L38-L63
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken))
{
    switch (evt)
    {
        case AgentResponseUpdateEvent e:
            if (e.Update.Contents.OfType&lt;FunctionCallContent&gt;().FirstOrDefault() is { } call)
            {
                cli.ToolStart(
                    e.ExecutorId,
                    call.Name,
                    call.Arguments?.ToDictionary(x =&gt; x.Key, x =&gt; x.Value?.ToString() ?? &quot;&quot;)
                    ?? new Dictionary&lt;string, string&gt;());
            }
            break;
    }
}</code></pre><p>SK has explicit invocation filters in use, while MAF currently uses workflow event interception. MAF middleware APIs are available, and I will likely move this interception logic into middleware in a future iteration.</p><p>As an extra reference, here is a clean middleware example from <a href="https://github.com/rwjdk?ref=huuhka.net">Rasmus Wulff Jensen&apos;s</a> <a href="https://github.com/rwjdk/MicrosoftAgentFrameworkSamples/blob/main/src/UsingRAGInAgentFramework/Program.cs?ref=huuhka.net#L132-L157">samples repo</a>:</p><pre><code class="language-csharp">AIAgent agentWithTools = client
    .GetChatClient(&quot;gpt-4.1&quot;)
    .AsAIAgent(
        instructions: &quot;You are an expert a set of made up movies given to you (aka don&apos;t consider movies from your world-knowledge)&quot;,
        tools: [AIFunctionFactory.Create(searchTool.SearchVectorStore)]
    ).AsBuilder()
    .Use(FunctionCallMiddleware)
    .Build();

async ValueTask&lt;object?&gt; FunctionCallMiddleware(
    AIAgent callingAgent,
    FunctionInvocationContext context,
    Func&lt;FunctionInvocationContext, CancellationToken, ValueTask&lt;object?&gt;&gt; next,
    CancellationToken cancellationToken)
{
    StringBuilder functionCallDetails = new();
    functionCallDetails.Append($&quot;- Tool Call: &apos;{context.Function.Name}&apos;&quot;);
    if (context.Arguments.Count &gt; 0)
    {
        functionCallDetails.Append($&quot; (Args: {string.Join(&quot;,&quot;, context.Arguments.Select(x =&gt; $&quot;[{x.Key} = {x.Value}]&quot;))}&quot;);
    }

    Utils.Gray(functionCallDetails.ToString());

    return await next(context, cancellationToken);</code></pre><p><strong>Docs:</strong></p><ul><li><a href="https://learn.microsoft.com/agent-framework/agents/middleware/defining-middleware/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">MAF middleware</a></li><li><a href="https://learn.microsoft.com/semantic-kernel/concepts/enterprise-readiness/filters/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">SK filters</a></li></ul><h3 id="still-a-few-rough-edges-in-the-prebuilt-workflows">Still a few rough edges in the prebuilt workflows</h3><p>This was my biggest practical friction point in the rewrite. <code>Handoff</code> did not really work as expected with Human In The Loop (HITL) interactions in 1.0.0-rc1, and I had to implement a custom loop around the handoff workflow to get user input and feed it back into the workflow. Also, <code>Magentic</code> support was completely missing from the C# version of the package.</p><p>My SK handoff flow uses <code>InteractiveCallback</code> directly on orchestration. In my MAF demo (<code>Microsoft.Agents.AI.Workflows</code> <code>1.0.0-rc1</code>), built-in handoff did not emit the <code>RequestInfoEvent</code> pattern I expected for a canonical HITL loop. Thus the callback never triggers like it does in the SK version, and I had to implement a custom loop around the workflow execution to feed user input back into the workflow. It&apos;s a bit rough and I probably would not use it in a production scenario, but it works for demo purposes.</p><p><strong>Before (SK):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/semanticKernel/Runners/HandoffRunner.cs#L50-L68
var orchestration = new HandoffOrchestration(...)
{
    InteractiveCallback = () =&gt;
    {
        var input = responses.Count &gt; 0 ? responses.Dequeue() : &quot;No, bye&quot;;
        return ValueTask.FromResult(new ChatMessageContent(AuthorRole.User, input));
    }
};</code></pre><p><strong>After (MAF):</strong></p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runners/HandoffRunner.cs#L48-L66
while (true)
{
    var workflow = CreateHandoffWorkflow(); // recreated each turn
    var turnResults = await WorkflowRunner.ExecuteAsync(workflow, messages, cli);

    foreach (var msg in turnResults.Where(m =&gt; m.Role == ChatRole.Assistant))
    {
        messages.Add(new ChatMessage(ChatRole.Assistant, msg.Text!) { AuthorName = msg.AuthorName });
    }

    if (!_simulatedResponses.TryDequeue(out var userResponse))
        break;

    messages.Add(new ChatMessage(ChatRole.User, userResponse));
}</code></pre><p>Why this likely happens (at least in this RC shape):</p><ul><li><code>AgentWorkflowBuilder.CreateHandoffBuilderWith(...)</code> is built around tool-based handoff between agents.</li><li><code>RequestInfoEvent</code> is tied to external request/response ports (<code>RequestPort</code>) and request flow handling.</li><li>The built-in handoff builder path does not automatically model that external request boundary in the same way, so no natural user-input request event showed up for my scenario.</li></ul><p>In other words, HITL is absolutely possible in MAF workflows, but with handoff I would need a custom workflow that explicitly introduces request/response points where human input is required. That was out of scope for this demo app translation.</p><p><strong>Docs:</strong></p><ul><li><a href="https://learn.microsoft.com/semantic-kernel/frameworks/agent/agent-orchestration/handoff/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">SK handoff orchestration</a></li><li><a href="https://learn.microsoft.com/agent-framework/workflows/?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">MAF workflows overview</a></li><li><a href="https://learn.microsoft.com/dotnet/api/microsoft.agents.ai.workflows.handoffsworkflowbuilder?view=agent-framework-dotnet-latest&amp;ref=huuhka.net"><code>HandoffsWorkflowBuilder</code> API</a></li><li><a href="https://learn.microsoft.com/dotnet/api/microsoft.agents.ai.workflows.requestinfoevent?view=agent-framework-dotnet-latest&amp;ref=huuhka.net"><code>RequestInfoEvent</code> API</a></li><li><a href="https://learn.microsoft.com/dotnet/api/microsoft.agents.ai.workflows.requestport?view=agent-framework-dotnet-latest&amp;ref=huuhka.net"><code>RequestPort</code> API</a></li><li><a href="https://github.com/rwjdk/MicrosoftAgentFrameworkSamples/blob/main/src/Workflow.Handoff/Program.cs?ref=huuhka.net#L24-L34">Rasmus Wulff Jensen&apos;s sample (handoff)</a></li><li><a href="https://github.com/rwjdk/MicrosoftAgentFrameworkSamples/blob/main/src/Workflow.HumanInTheLoop/Program.cs?ref=huuhka.net#L35-L49">Rasmus Wulff Jensen&apos;s sample (HITL via request port)</a></li></ul><h3 id="quick-note-on-custom-graph-workflows">Quick note on custom graph workflows</h3><p>These feel powerful, and likely where many production use cases will end up.</p><p>I only did a first pass here, but even that looked promising:</p><pre><code class="language-csharp">// https://github.com/DrBushyTop/MultiAgentSemanticKernel/blob/feat/agentFramework/Runners/GraphRunner.cs#L251-L264
var builder = new WorkflowBuilder(startExecutor);
builder.AddFanOutEdge(startExecutor, [qualityReviewer, securityReviewer]);
builder.AddFanInBarrierEdge([qualityReviewer, securityReviewer], combinerExecutor);
builder.AddEdge(combinerExecutor, reportGenerator);
builder.WithOutputFrom(reportGenerator);</code></pre><p>I will do a separate deep-dive post on graph workflows later as I gain more experience with using them in production applications. All in all, I still feel like the current LLM models are so powerful that it might not make much sense to make your code more complex with custom graph workflows unless you have a very specific need for it, but it&apos;s good to have the option there when you do.</p><p>I tend to think you&apos;ll get very far with building your Primary / Subagent flows like many coding agents do: Subagents exist to protect the context window of the Primary agent. <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/">More on that in my previous post here</a>.</p><h3 id="final-take">Final take</h3><p>All in all, MAF left a positive impression on me. The API is arguably more straightforward and easier to use than SK was (I had no experience with AutoGen), and I think it will be more approachable for new users. </p><p>I do still think Microsoft needs to make a somewhat clear value proposition on when to use MAF vs other competitors in the space like LangGraph, but I can see MAF being a strong choice for teams that are already invested in the Microsoft ecosystem and want a first-party solution with good integration with Azure AI services and C#.</p>]]></content:encoded></item><item><title><![CDATA[Preserving MCP session continuity with Redis]]></title><description><![CDATA[I ran into a fairly mundane MCP issue recently that only really shows up once the server stops living on one process forever: Sessions.]]></description><link>https://www.huuhka.net/preserving-mcp-session-continuity-with-redis/</link><guid isPermaLink="false">69e3c0326f3c900001bdaeb8</guid><category><![CDATA[AI]]></category><category><![CDATA[Azure]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[OpenCode]]></category><category><![CDATA[GitHub Copilot]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Tue, 10 Feb 2026 18:44:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/181749_kelly-sikkema-M6dAnUgiOlQ-unsplash.jpg" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Secure Enterprise AI Tooling On Azure theme:<br>- <a href="https://www.huuhka.net/securing-remote-mcp-servers-with-entra-id-without-breaking-reconnects/" rel="noreferrer">Securing remote MCP servers with Entra ID without breaking reconnects</a><br>- <a href="https://www.huuhka.net/preserving-mcp-session-continuity-with-redis/" rel="noreferrer">Preserving MCP session continuity with Redis</a><br>- <a href="https://www.huuhka.net/shipping-signed-config-updates-to-local-ai-tooling/" rel="noreferrer">Shipping signed config updates to local AI tooling</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/181749_kelly-sikkema-M6dAnUgiOlQ-unsplash.jpg" alt="Preserving MCP session continuity with Redis"><p>I ran into a fairly mundane MCP issue recently that only really shows up once the server stops living on one process forever.</p><p>The tool looked fine. The server looked fine. But existing client sessions could still lose continuity after a restart or rollout because the session state lived in memory. My LLM sessions in OpenCode and GitHub Copilot would just lose tools mid-conversation with no errors or warnings, which was not a great experience. Even forcing reconnects from plugins could not recover, so the only fix was restarting the process. I could handle that, but my users would not be thrilled about it.</p><p>The simple in-memory approach works right up until the process restarts, a new revision gets deployed, or traffic lands on another instance. The client still has the session ID and keeps using it. The new process no longer knows anything about that session. From the client&apos;s point of view the tool has just disappeared.</p><p>I ended up solving this by just adding a tiny Redis-based session store to preserve the critical continuity state across process boundaries. The shape of the solution ended up being pretty simple, and the infrastructure was refreshingly plain. I thought it might be worth sharing the details since this is a problem that other people are likely to run into as well.</p><h3 id="setup">Setup</h3><p>The setup here is fairly simple:</p><ul><li>The client establishes an MCP session and keeps using the returned session ID</li><li>The server framework stores session state in memory by default</li><li>After a restart, rollout, or replica change, that in-memory state is gone</li><li>The client is still behaving correctly, but the next process can no longer resolve the session</li></ul><p>That means the actual problem is not reconnecting the HTTP transport. It&apos;s preserving just enough session state outside process memory for the next instance to accept the session again.</p><p>Before getting into the Redis part, it&apos;s worth quickly looking at how MCP sessions work over HTTP, because that is really where the problem starts.</p><h3 id="mcp-sessions-over-http">MCP sessions over HTTP</h3><p>In MCP, the client and server begin with an initialization phase where they negotiate protocol version and capabilities. After that, they move into normal operation. In the Streamable HTTP transport, the server may also assign an <code>Mcp-Session-Id</code> header during initialization, and if it does, the client is expected to send that session ID on subsequent requests.</p><p>The official MCP transport spec is quite explicit here. Streamable HTTP sessions are optional, not mandatory. A server may assign a session ID during initialization, and if it does, later requests use that session ID. If the server later returns <code>404</code> for that session, the client is expected to reinitialize.</p><p>Relevant references:</p><ul><li><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle?ref=huuhka.net">MCP Lifecycle</a></li><li><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports?ref=huuhka.net#session-management">MCP Streamable HTTP transport and session management</a></li></ul><p>That means there are really two broad shapes you can end up with on HTTP: a stateful server that keeps per-session state and expects requests to continue that session, or a stateless server that treats each request independently and avoids session tracking altogether.</p><h3 id="stateful-vs-stateless">Stateful vs stateless</h3><p>I built this server using the <a href="https://github.com/modelcontextprotocol/csharp-sdk?ref=huuhka.net" rel="noreferrer">Microsoft C# MCP SDK</a>. In that SDK, HTTP transport is stateful by default. The SDK docs are also explicit that <code>Stateless</code> defaults to <code>false</code>, and that enabling stateless mode stops using <code>MCP-Session-Id</code> and creates a fresh server context for each request.</p><p>Reference: <a href="https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.AspNetCore.HttpServerTransportOptions.html?ref=huuhka.net">MCP C# SDK `HttpServerTransportOptions</a>`</p><p>That distinction matters quite a lot operationally. If you stay stateful, you get a more session-oriented model, but you also need to think about what happens when the process that originally held the session state disappears. If you go stateless, horizontal scaling and rollouts become much simpler, but you give up features that depend on durable server-side session state.</p><p>In my case, I went into the implementation a bit too quickly and accepted the default stateful model. Looking back, part of this specific continuity problem could probably have been avoided if I had first asked whether the server really needed to be stateful at all.</p><p>That is not to say the stateful route was wrong. It just means that Redis ended up solving a problem created partly by an earlier transport-mode choice.</p><h3 id="flow">Flow</h3><p>At a high level, the recovery path looks like this:</p><ol><li>The client initializes a session and receives a session ID.</li><li>The server stores the continuity-critical initialize payload in Redis under that session ID.</li><li>A later request arrives at a different process, or after a restart.</li><li>If the session is missing locally, the server looks up the stored initialization state and restores enough context to continue.</li></ol><h3 id="what-needs-to-survive">What needs to survive</h3><p>I think the most useful thing here is to keep the requirement narrow.<br>You usually don&apos;t need to make the whole server runtime durable. You just need to preserve enough state for the next instance to reconstruct the MCP session in a way the framework accepts.</p><p>In the implementation I looked at, the important part was the original initialize payload. That&apos;s what got written into distributed cache against the session ID.</p><p>That felt like the right level of persistence. Small JSON payloads with a TTL, not some attempt to recreate arbitrary process memory after a crash.</p><p>Once you keep the scope that tight, the Redis part becomes very plain. On session initialization, serialize the initialize payload and write it to a namespaced cache key. On a migration attempt, look the session up by ID and hand the stored payload back to the framework.</p><h3 id="why-in-memory-sessions-are-not-enough">Why in-memory sessions are not enough</h3><p>This is obvious in hindsight, but it&apos;s easy not to care about until you see it happen.<br>An MCP client initializes a session and gets back a session ID. It keeps using that session ID for later requests. Then the server restarts. The client is still behaving perfectly reasonably, but the new process has no idea what that session ID means. If all session state is in memory, that behavior is expected.</p><p>You notice it more once there are rolling deployments, multiple replicas, or just longer-lived coding sessions that don&apos;t fit the &quot;connect, do one tiny thing, disconnect&quot; model. Sticky sessions can make it less frequent, but they don&apos;t solve deployments. Once the old revision is gone, the in-memory session state is gone with it.</p><p>That&apos;s the point where a tiny distributed session store starts making sense.<br>Of course, the other valid conclusion is that if your server does not need stateful MCP sessions in the first place, stateless mode may be the better answer. Redis is useful here, but it is still compensating for a stateful design choice.</p><h3 id="the-solution">The solution</h3><p>The shape I like here is very small. Save the initialize payload on session creation. Store it under a service-specific prefix plus the session key. Give it a sliding expiration and a slightly longer absolute ceiling. When a request arrives with a session that&apos;s missing locally, ask Redis whether the migration state exists.</p><p>That can be expressed in a fairly compact helper:</p><pre><code class="language-ts">type SessionInitPayload = {
  protocolVersion: string;
  clientInfo: { name: string; version: string };
  capabilities?: Record&lt;string, unknown&gt;;
};

interface CacheClient {
  set(key: string, value: string, ttlSeconds: number): Promise&lt;void&gt;;
  get(key: string): Promise&lt;string | null&gt;;
}

class SessionMigrationStore {
  constructor(
    private readonly cache: CacheClient,
    private readonly keyPrefix: string,
    private readonly ttlHours: number,
  ) {}

  async save(sessionId: string, payload: SessionInitPayload): Promise&lt;void&gt; {
    const ttlSeconds = Math.max(this.ttlHours, 1) * 60 * 60;
    const key = `${this.keyPrefix}session:${sessionId}`;
    await this.cache.set(key, JSON.stringify(payload), ttlSeconds);
  }

  async restore(sessionId: string): Promise&lt;SessionInitPayload | null&gt; {
    const key = `${this.keyPrefix}session:${sessionId}`;
    const raw = await this.cache.get(key);
    return raw ? (JSON.parse(raw) as SessionInitPayload) : null;
  }
}</code></pre><p>There are only a couple of details there that I think really matter. One is namespacing. If several MCP servers share the same Redis, they shouldn&apos;t all write to the same naked <code>session:&lt;id&gt;</code> shape. The other is keeping the TTL model explicit. In the implementation here the session state used sliding expiration with a slightly longer absolute bound, which felt about right for development-oriented session continuity without pretending sessions should live forever.</p><h3 id="configuration-and-failure-behavior">Configuration and failure behavior</h3><p>I&apos;d definitely keep this behind configuration.</p><p>If the Redis connection string is present, enable the distributed session migration path. If it&apos;s not, run the normal in-memory mode and accept that sessions die on restart. That&apos;s a perfectly fine split between simpler environments and deployed environments that actually need continuity.</p><p>The failure behavior should stay straightforward too. If Redis doesn&apos;t have the session key anymore, the server shouldn&apos;t crash or wedge itself trying to be clever. It should log the miss, return the normal session-not-found behavior, and let the client reinitialize. That keeps session continuity as a best-effort resilience feature instead of making the whole service startup path depend on one cache lookup.</p><p>I think that distinction matters quite a lot. There&apos;s a difference between &quot;this service can preserve sessions across rollouts when the cache is available&quot; and &quot;this service cannot function unless the cache is healthy&quot;. I&apos;d aim for the first one.</p><h3 id="infrastructure">Infrastructure</h3><p>The infrastructure shape was about as small as I&apos;d want it to be. There&apos;s one small Redis instance with an LRU-style eviction policy. The connection string is stored in a secret store. The app reads that secret through managed identity. Local development can point at a local Redis if you want to exercise the feature outside Azure.</p><p>That felt nicely proportional to the problem. The state is tiny and short-lived, so the cache doesn&apos;t need to be fancy. It just needs to exist outside process memory.<br>The one extra nuance here is that if you also care about stream resumability, the session migration store is only half of the story. You need the relevant stream state outside process memory as well. The pattern is the same though. The point is still to move the continuity-critical state out of the lifetime of one server process.</p><h3 id="what-this-solves">What this solves</h3><p>What it solves is the very mundane operational pain of existing MCP sessions dying every time the server is redeployed or restarted. It also helps when traffic lands on a different replica than the one that originally saw the session.</p><p>What it doesn&apos;t solve is every other durability problem you could imagine. It doesn&apos;t make in-flight tool execution survive a hard process death. It doesn&apos;t make sessions immortal after TTL expiry or eviction. It doesn&apos;t smooth over auth changes that invalidate the restored caller context. And it definitely doesn&apos;t fix every reconnect quirk a client might have.</p><h3 id="wrap-up">Wrap up</h3><p>MCP sessions are stateful in a way that starts to matter operationally fairly quickly.</p><p>If you want existing sessions to survive rollouts, the server needs somewhere outside process memory to remember them. In my case, the amount of state that actually needed to survive was surprisingly small. That&apos;s why Redis ended up being a good fit: the problem was small, stateful, and short-lived in exactly the way Redis tends to handle well.</p><p>If I were adding a new internal MCP service today, I&apos;d treat this as a first-class production concern from the start instead of waiting until the first rollout teaches the lesson for me.<br></p>]]></content:encoded></item><item><title><![CDATA[Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints]]></title><description><![CDATA[I've been building out AI platforms on Azure, and as part of that ended up spending a fair bit of time with both the newer AI Gateway story in API Management and the imported Microsoft Foundry endpoint flow.]]></description><link>https://www.huuhka.net/practical-experiences-with-azure-apim-ai-gateway-and-imported-foundry-endpoints/</link><guid isPermaLink="false">69ad62ab26d9b800016696f4</guid><category><![CDATA[AI]]></category><category><![CDATA[API Management]]></category><category><![CDATA[Bicep]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Sun, 08 Feb 2026 12:20:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/81155_81155_image.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is a part of a larger AI Dev Platform theme:<br>- <a href="https://www.huuhka.net/ai-dev-platform-fundamentals/" rel="noreferrer">Azure AI Dev Platform Fundamentals</a><br>- <a href="https://www.huuhka.net/practical-experiences-with-azure-apim-ai-gateway-and-imported-foundry-endpoints/" rel="noreferrer">Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a><br>- <a href="https://www.huuhka.net/connecting-opencode-with-microsoft-foundry-models/" rel="noreferrer">Connecting OpenCode with Microsoft Foundry Models</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/81155_81155_image.png" alt="Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints"><p><a href="https://www.huuhka.net/ai-dev-platform-fundamentals/" rel="noreferrer">I&apos;ve been building out AI platforms on Azure</a>, and as part of that ended up spending a fair bit of time with both the newer AI Gateway story in API Management and the imported Microsoft Foundry endpoint flow. On paper the split is fairly simple. You can either enable <a href="https://learn.microsoft.com/en-us/azure/foundry/configuration/enable-ai-api-management-gateway-portal?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">AI Gateway directly in Foundry</a>, or you can <a href="https://learn.microsoft.com/en-us/azure/api-management/azure-ai-foundry-api?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">import a Microsoft Foundry API into APIM</a> and manage it there.</p><p>In practice, I found myself needing both. This post will go through the reasons why, where I think the current split makes sense, and which parts of the setup still feel a bit awkward to me.</p><h3 id="platform-concerns">Platform concerns</h3><p>Once a few teams start using shared LLM capacity, governance stops being a boring afterthought.</p><p>The requirement is usually not &quot;block everything until architecture is perfect&quot;. It is more like this:</p><ul><li>usage should be fair</li><li>usage should be observable</li><li>usage should not let one team accidentally starve everyone else</li><li>prepaid capacity should be used well</li><li>overflow should still have somewhere to go</li></ul><p>That last one matters more than it first appears. If you have bought model capacity up front, you obviously want to get value out of it. At the same time, real workloads are rarely flat. If traffic spikes, you may still want to route excess usage somewhere else instead of just failing calls.</p><p><em>As a small sidenote though, at least here in Finland and at the scale most companies around me are operating, buying large chunks of prepaid capacity from Microsoft is still not that common. The cost is usually just too high compared to what they are actually getting out of the deal, so pay as you go is still the more realistic default for many teams. I still think the routing and governance model matters, but the &quot;protect the expensive prepaid capacity&quot; story is often more of a future-looking platform concern than today&apos;s norm.</em></p><p>APIM is not the whole answer to that, but to me it is still the most practical place to put the control logic. Microsoft calls out token governance, load balancing, semantic caching, content safety, and observability as part of the <a href="https://learn.microsoft.com/en-us/azure/api-management/genai-gateway-capabilities?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">AI Gateway story for APIM</a> and that&apos;s roughly the same shopping list I tend to have anyway when building shared AI access layers.</p><h3 id="implementing-both-paths">Implementing both paths</h3><p>If you want the quick path, you can enable <a href="https://learn.microsoft.com/en-us/azure/foundry/configuration/enable-ai-api-management-gateway-portal?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">AI Gateway directly in Foundry</a> and manage model limits there. If you want more control, you can <a href="https://learn.microsoft.com/en-us/azure/api-management/azure-ai-foundry-api?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">import a Microsoft Foundry API into APIM</a> and use the broader APIM policy surface.</p><p>The two routes still don&apos;t line up feature for feature. In my setup, the Foundry-managed AI Gateway route didn&apos;t give me everything I wanted from the APIM side. The biggest gap was token visibility. I wanted APIM-side token metrics with my own dimensions and dashboarding model, and I also wanted an explicit OpenAI-compatible path for certain clients.</p><p>So I ended up exposing two API surfaces on the same APIM hostname:</p><ul><li>one more generic AI Gateway style path for Foundry traffic</li><li>one imported Foundry models path for the OpenAI-compatible endpoint</li></ul><p>It&apos;s not very elegant, rather it&apos;s more the current shape of the platform when you care about the operational details.</p><p>My guess is that this split shrinks over time and the capabilities converge. Right now though, I still had to think about which path gave me which capability, and before I actually dove into the documentations I was thinking just the AI Gateway would bring me everything.<br><br>The practical shape was roughly this:</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/81155_image.png" class="kg-image" alt="Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints" loading="lazy" width="1831" height="640"></figure><p>That probably looks slightly silly at first glance, but it let me keep one hostname and one platform entrypoint while still exposing two slightly different integration styles.</p><ol><li>I wanted the more direct AI Gateway style shape that maps well to the Foundry story.</li><li>I also wanted the imported endpoint shape where APIM policy behavior and metrics felt more explicit for my use case.</li></ol><p>If you are only doing one of those, this probably sounds more complex than necessary. That is fair. If your needs are simple, the Foundry-managed path is likely enough. But if you are building a shared platform and care about compatibility, control, and reporting, it gets easier to justify. </p><p>## When I would use AI Gateway vs imported Foundry APIs</p><p>If I wanted to simplify the decision for myself today, I would phrase it like this.</p><p><strong>Use the Foundry-managed AI Gateway when...</strong></p><ul><li>project-level token limits are enough- you want the Foundry control-plane experience</li><li>you want to get going quickly and can live with preview-era boundaries</li></ul><p>That path is attractive precisely because it is less APIM-shaped. You stay in Foundry, wire the gateway up, enable projects, and move on. For some teams that is exactly the correct level of abstraction. The <a href="https://learn.microsoft.com/en-us/azure/foundry/configuration/enable-ai-api-management-gateway-portal?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Foundry AI Gateway docs</a> and the <a href="https://learn.microsoft.com/en-us/azure/foundry/control-plane/how-to-enforce-limits-models?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">model token limit docs</a> describe that path fairly well.</p><p><strong>Use imported Foundry APIs in APIM when...</strong></p><ul><li>you need policy-level control</li><li>you want your own telemetry dimensions- you care about detailed monitoring and reporting</li><li>you need the endpoint shape to be predictable for existing clients- you already think of APIM as the platform entrypoint anyway</li></ul><p>You can use managed identity auth to the backend, attach <a href="https://learn.microsoft.com/en-us/azure/api-management/llm-token-limit-policy?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">token limiting</a>, <a href="https://learn.microsoft.com/en-us/azure/api-management/llm-emit-token-metric-policy?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">token metric emission</a>, <a href="https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-llm-logs?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">AI gateway logging</a>, and if you want to, <a href="https://learn.microsoft.com/en-us/azure/api-management/azure-openai-enable-semantic-caching?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">semantic caching</a> too. Again, I don&apos;t think this split is permanent. I just think it still matters today.</p><h3 id="sidebar-why-i-keep-using-user-assigned-managed-identities-everywhere">Sidebar: why I keep using user-assigned managed identities everywhere</h3><p>This is not really a special AI Gateway decision for me. It&apos;s just something I do for basically all of my applications. Whenever possible, I prefer to separate identity and permissions from the infrastructure choice itself.</p><p>That means if I deploy APIM, a Function App, a Container App, or something else, I would usually rather attach a user-assigned managed identity than let the resource identity be completely implicit. Not because the default identity model is wrong, but because the user-assigned approach gives me free flexibility later. If I redeploy the infra, swap one hosting choice for another, split something up, or move a permission boundary, I can keep the identity and its RBAC assignments more stable. In practice that makes the permission story easier to reason about over time.</p><p>So in this case I did the same thing with APIM. Instead of treating the default identity behavior as part of the gateway feature itself, I attached a dedicated user-assigned identity and used that identity when calling the Foundry backend.</p><p>That looks roughly like this in a simplified form:</p><pre><code class="language-bicep">resource gatewayIdentity &apos;Microsoft.ManagedIdentity/userAssignedIdentities@2025-01-31-preview&apos; = {
  name: &apos;id-apim-shared-ai&apos;
  location: location
}

resource apiManagement &apos;Microsoft.ApiManagement/service@2025-03-01-preview&apos; = {
  name: &apos;apim-shared-ai&apos;
  location: location
  identity: {
    type: &apos;UserAssigned&apos;
    userAssignedIdentities: {
      &apos;${gatewayIdentity.id}&apos;: {}
    }
  }
  properties: {
    publisherName: &apos;Shared AI Gateway&apos;
    publisherEmail: &apos;platform@example.net&apos;
  }
}</code></pre><p>And the APIM backend auth shape is similarly straightforward:</p><pre><code class="language-xml">&lt;inbound&gt;
  &lt;set-backend-service backend-id=&quot;foundry-backend&quot; /&gt;
  &lt;authentication-managed-identity
      resource=&quot;https://ai.azure.com/&quot;
      client-id=&quot;11111111-2222-3333-4444-555555555555&quot; /&gt;
  &lt;base /&gt;
&lt;/inbound&gt;</code></pre><p>For the OpenAI-compatible style endpoints, the target resource would typically be <code>https://cognitiveservices.azure.com/</code> instead. The relevant docs here are <a href="https://learn.microsoft.com/en-us/azure/api-management/api-management-authenticate-authorize-ai-apis?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">auth for AI APIs in APIM</a> and <a href="https://learn.microsoft.com/en-us/azure/api-management/backends?ref=huuhka.net#configure-managed-identity-for-authorization-credentials?WT.mc_id=AZ-MVP-5003781">managed identity configuration for APIM backends</a>.</p><p>RBAC-wise, the important part was simply granting that identity the backend access it needed. For my case that meant the <code>Cognitive Services User</code> role on the Foundry account and project too(?). Can&apos;t actually remember.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x26A0;&#xFE0F;</div><div class="kg-callout-text">This can also lead to some issues in some cases. For example it turns out the &quot;New Foundry&quot; standard agent setup did not work at all until I switched back to the system assigned managed identity. </div></div><h3 id="observability-payoff-with-apim">Observability payoff with APIM</h3><p>I wanted to get some telemetry on usage, which APIM thankfully provided:</p><ul><li>one place to enrich requests with stable platform metadata- one place to emit token metrics</li><li>one place to send gateway and LLM logs onward</li><li>dashboards that are useful to platform owners, not just to whoever happens to be staring at a single model deployment</li></ul><p>I had the gateway add a few platform-specific headers and baggage values, and I also hashed the incoming user object id before using it as a metrics dimension. That gave me a reasonably privacy-safe way to answer questions like &quot;who is using the platform&quot;, &quot;which tools are hot&quot;, and &quot;which traffic is burning the most tokens&quot; without spraying raw identifiers around.</p><p>The token metrics part was powered by <a href="https://learn.microsoft.com/en-us/azure/api-management/llm-emit-token-metric-policy?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">llm-emit-token-metric</a>. A simplified policy example looks like this:</p><pre><code class="language-xml">&lt;llm-emit-token-metric namespace=&quot;shared-ai&quot;&gt;
  &lt;dimension name=&quot;API ID&quot; /&gt;
  &lt;dimension name=&quot;Operation ID&quot; /&gt;
  &lt;dimension name=&quot;user_hash&quot; value=&quot;@(context.Request.Headers.GetValueOrDefault(&amp;quot;x-user-id-hash&amp;quot;, &amp;quot;&amp;quot;))&quot; /&gt;
  &lt;dimension name=&quot;deployment&quot; value=&quot;@((string)context.Request.MatchedParameters[&amp;quot;deployment-id&amp;quot;])&quot; /&gt;
&lt;/llm-emit-token-metric&gt;</code></pre><p>The flow of telemetry looks pretty much like this:</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/8126_image.png" class="kg-image" alt="Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints" loading="lazy" width="1230" height="680"></figure><p>The logging side is covered in the <a href="https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-llm-logs?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">LLM logging docs</a>, and the dedicated log table reference is <a href="https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/apimanagementgatewayllmlog?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">ApiManagementGatewayLlmLog</a>.</p><p>A KQL example for a dashboard could look like this:</p><pre><code class="language-kusto">AppMetrics
| where TimeGenerated &gt; ago(24h)
| where AppRoleName has &quot;apim-shared-ai&quot;
| where Name == &quot;Total Tokens&quot;
| extend dims = todynamic(Properties)
| extend user_hash = tostring(dims[&quot;user_hash&quot;])
| where isnotempty(user_hash)
| summarize total_tokens = sum(todouble(Sum)) by user_hash
| top 20 by total_tokens desc</code></pre><h3 id="semantic-caching">Semantic Caching</h3><p>I have not yet found a compelling use case for APIM semantic caching in my workloads, so I have not enabled it. The main draw of the feature is that it can automatically cache semantically similar requests and responses.</p><p>Most of my LLM workload is either coding-oriented or tied to RAG-style scenarios. In both cases, two prompts that look quite similar can still reasonably require very different outputs. Because of that, I have not yet felt confident that semantic caching would lead to more benefits than actual harm in my specific scenario.</p><p>There is also the operational side. If I want APIM semantic caching, I need an external Redis-compatible cache with the right capabilities. The <a href="https://learn.microsoft.com/en-us/azure/api-management/azure-openai-enable-semantic-caching?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">semantic caching docs</a> call out Azure Managed Redis and RediSearch requirements. Skipping this let&apos;s me avoid an extra moving parts.</p><p>So for now I am mostly depending on the provider or model-side caching behavior where it exists, and leaving APIM semantic caching out of the picture. That might change later. Right now it just does not feel like the correct optimization target for my workloads.</p><h3 id="the-weird-100k-free-requests-bootstrap-story">The weird 100k free requests bootstrap story</h3><p>One small thing that felt oddly fuzzy to me was the messaging around the free requests benefit when creating AI Gateway through Foundry. <a href="https://azure.microsoft.com/en-us/pricing/details/api-management/?ref=huuhka.net" rel="noreferrer">The pricing page </a>just has a single * row you need to search for.</p><p>The docs point out that AI Gateway includes a free tier and refer to pricing details from the <a href="https://learn.microsoft.com/en-us/azure/foundry/configuration/enable-ai-api-management-gateway-portal?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Foundry AI Gateway setup page</a>. At the time I was doing this, the portal and docs wording around the first 100k requests sounded nice, but I never found a verification path to see that I&apos;m actually getting them (and what determines it).</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/81212_image.png" class="kg-image" alt="Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints" loading="lazy" width="990" height="794"></figure><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/81215_Screenshot%202026-03-08%20at%2012.36.55.png" class="kg-image" alt="Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints" loading="lazy" width="990" height="794"></figure><p>That led me to a slightly dumb workaround. I ended up doing a three-phase deployment:</p><ol><li>deploy the rest of the platform without APIM management</li><li>create the AI Gateway / APIM association from the Foundry portal first</li><li>then let my Bicep adopt and configure the APIM side afterward</li></ol><p>Does that sound a bit silly? Yes. Thankfully we only need to do this once, so I can live with it.</p><h3 id="links-references">Links / references</h3><ul><li><a href="https://learn.microsoft.com/en-us/azure/api-management/genai-gateway-capabilities?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">AI gateway in Azure API Management</a></li><li><a href="https://learn.microsoft.com/en-us/azure/foundry/configuration/enable-ai-api-management-gateway-portal?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Configure AI Gateway in your Foundry resources</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/azure-ai-foundry-api?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Import a Microsoft Foundry API</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/api-management-authenticate-authorize-ai-apis?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Authenticate and authorize access to LLM APIs by using Azure API Management</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/backends?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Backends in API Management</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/llm-token-limit-policy?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Limit large language model API token usage</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/llm-emit-token-metric-policy?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Emit metrics for consumption of large language model tokens</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-llm-logs?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Log token usage, prompts, and completions for LLM APIs</a></li><li><a href="https://learn.microsoft.com/en-us/azure/api-management/azure-openai-enable-semantic-caching?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Enable semantic caching for LLM APIs in Azure API Management</a></li><li><a href="https://learn.microsoft.com/en-us/azure/foundry/control-plane/how-to-enforce-limits-models?WT.mc_id=AZ-MVP-5003781&amp;ref=huuhka.net">Enforce token limits for models</a><br></li></ul>]]></content:encoded></item><item><title><![CDATA[Shipping signed config updates to local AI tooling]]></title><description><![CDATA[Adventures in tool distribution]]></description><link>https://www.huuhka.net/shipping-signed-config-updates-to-local-ai-tooling/</link><guid isPermaLink="false">69de7a97b92fba00013b6255</guid><category><![CDATA[AI]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[DevOps]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Wed, 28 Jan 2026 18:39:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141741_flyd-BH0Wwlmv2oA-unsplash.jpg" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Secure Enterprise AI Tooling On Azure theme:<br>- <a href="https://www.huuhka.net/securing-remote-mcp-servers-with-entra-id-without-breaking-reconnects/" rel="noreferrer">Securing remote MCP servers with Entra ID without breaking reconnects</a><br>- <a href="https://www.huuhka.net/preserving-mcp-session-continuity-with-redis/" rel="noreferrer">Preserving MCP session continuity with Redis</a><br>- <a href="https://www.huuhka.net/shipping-signed-config-updates-to-local-ai-tooling/" rel="noreferrer">Shipping signed config updates to local AI tooling</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141741_flyd-BH0Wwlmv2oA-unsplash.jpg" alt="Shipping signed config updates to local AI tooling"><p>I&apos;ve been building local AI tooling setups where the interesting part is not only the binary. A big part of the product is the surrounding config: agents, plugins, MCP wiring, commands, auth glue, and the small bits of structure that make the local tool actually useful inside one organization.</p><p>At some point you usually want a way to update that remotely. Especially when the number of users grows, manual updates become unfeasible. You want to be able to ship new commands, tweak agent behavior, add new plugins, and so on without having to ask everybody to download a new version of the tool. The users might also just be non-technical, and we want to make things as seamless for them as possible.</p><p>If a remote service can tell a local coding tool to replace parts of its config, then  that update channel is part of the workstation trust boundary. That can be extremely useful. It can also become a very self-inflicted security issue if the model is sloppy.</p><h3 id="the-risk-here">The risk here</h3><p>If the update can change plugin code, command files, MCP settings, auth behavior, or the set of managed local files, then the update plane is effectively allowed to change how the tool behaves. That&apos;s close enough to code distribution that I don&apos;t think it should be treated casually. Add to this the fact that the tool is running locally and has access to the user&apos;s files, and you have a recipe for a potential disaster if the update path is compromised.</p><p>I also didn&apos;t want the local tool to become fragile. If the update API is down, if auth has expired, or if verification fails, developers should still be able to open the tool and work.</p><p>That gave me two requirements that sound contradictory until you spell them out. The update path should fail closed. Startup should fail open. You can reject an update aggressively without rejecting the whole application startup.</p><h3 id="so-why-isnt-a-hash-enough">So why isn&apos;t a hash enough?</h3><p>The first instinct here is usually to hash the ZIP and call it done. That helps, but only partly. If the same actor can tamper with both the bundle and the metadata that tells the client which bundle to download, plain integrity checking doesn&apos;t really solve the trust problem. The attacker just changes the ZIP and the hash together. </p><p>That&apos;s why I ended up with two different checks doing two different jobs. The manifest signature answers whether the update description came from a trusted publisher. The ZIP hash answers whether the downloaded bytes match the signed manifest. That split felt right: Trust the publisher first, then trust the bytes.</p><p>I also found it useful to sign a small canonical payload instead of every cosmetic field in the manifest. The important fields were the channel, version, ZIP path, timestamp and SHA-256. That kept the signed surface focused on the security-relevant identity of the release rather than whatever extra metadata I might want to add later.</p><h3 id="the-azure-implementation">The Azure implementation</h3><p>The service side was quite simple. There was one endpoint that returned the latest manifest for a channel and another that returned the ZIP bytes for a specific version. The manifests and bundles lived in blob storage. The signing key stayed server-side. The client carried only a small trusted map of public keys keyed by <code>keyId</code>.</p><p>That gave the update cycle a fairly clean shape: The client asks for the latest manifest. It verifies the manifest signature. If the version is newer, it asks for the exact ZIP for that version. Then it hashes the ZIP, compares it to the signed manifest, stages the update locally, and only after that applies it.</p><p>The publisher side was similarly straightforward. Build the config bundle, hash it, construct the canonical payload, sign it with Ed25519, write out the version-specific manifest, and then update the channel-level latest manifest.</p><h3 id="key-rotation">Key rotation</h3><p>I didn&apos;t want signing key rotation to become a mini-incident. So the client trusts a very small set of public keys and the manifest includes the `keyId` it was signed with.</p><p>That gives a practical rotation story. Ship client support for the new public key first. Then switch the publisher to sign with the new private key. Keep the old key around for a transition period. Remove it later. I think this is one of those areas where simpler is better. You don&apos;t need a giant PKI story for this kind of internal updater. You need one trustworthy signing path, a clean verification path, and a rotation model that people will actually be willing to execute during normal delivery work.</p><p>The validation side should be strict though. Unknown key ID should fail. Invalid signature should fail. ZIP hash mismatch should fail. I wouldn&apos;t add any kind of &quot;probably fine&quot; behavior there.</p><p>For a tiny self-contained version of the flow, this is the heart of it:</p><pre><code class="language-js">const trustedPublicKeys = {
  k1: publicKey1,
  k2: publicKey2,
};

verifyManifestSignature(manifest, trustedPublicKeys);
verifyDownloadedBundle(manifest, zipBytes);</code></pre><p>Again, the important thing is the order.</p><h3 id="the-local-apply-flow">The local apply flow</h3><p>It&apos;s very easy to overfocus on signatures and underfocus on what happens on disk.</p><p>For me, the updater needed to be simple locally too. It should create a backup first, extract into a staging directory, validate the extracted paths, apply the managed updates, write the new version marker, and clean up afterwards. If something goes wrong in the middle, it should restore from the backup instead of leaving the user in some half-updated state.</p><p>The other thing that mattered was acknowledging that not every file should be handled the same way. Some paths are fully managed. Some need merge behavior. Some user-defined content should survive an update. If you ignore that, people eventually stop trusting the updater and start working around it. We also clearly mark the managed paths in the file system and in the config structure so that it&apos;s obvious what&apos;s up for grabs and what&apos;s not. Users want to customize their tooling, and we should not get in the way of that.</p><h3 id="update-failures-vs-startup">Update failures vs startup</h3><p>This was probably the most important product decision in the whole thing. The updater runs during startup, but it shouldn&apos;t own startup. If the update API is unavailable, auth has expired, blob storage has a bad day, or verification fails, the safe behavior is to log it, skip the update, and keep starting the tool. </p><p>I&apos;d much rather have somebody on yesterday&apos;s known-good config than locked out of the tool entirely because the update plane is having a rough morning. That&apos;s why the split needs to be very explicit. Update failures are hard failures for the update path. They&apos;re not hard failures for local startup. Thankfully a simple design leads to simple error handling. </p><p>From the user&apos;s side, we get one background check, one token refresh if it&apos;s warranted, and then move on. This shouldn&apos;t turn into a startup spinner that makes people wonder whether the tool has hung.</p><h3 id="what-worked-well">What worked well</h3><p>The main thing I liked was that it stayed small: </p><p>There&apos;s a metadata endpoint. There&apos;s a bundle endpoint. The signed part of the manifest is intentionally minimal. The client verifies signature first and hash second. The updater stages locally and backs up before apply. And none of the failure modes are allowed to take the whole tool down. That&apos;s a fairly modest amount of machinery for something that&apos;s operating on a sensitive boundary.</p><p>If I kept pushing on simplification, it would mostly be in the apply model. It&apos;s easy to accidentally build a tiny package manager here, and I don&apos;t think that&apos;s the right goal. The updater should be strict and trustworthy, not ambitious.</p><h3 id="wrap-up">Wrap up</h3><p>The main lesson for me was that shipping remote config to local AI tooling is close enough to shipping code that I think it deserves the same seriousness. That doesn&apos;t mean the implementation needs to be huge. In my case it was actually fairly small: signed manifests, versioned bundles, a couple of endpoints, staged local apply, and clear failure boundaries.</p>]]></content:encoded></item><item><title><![CDATA[Browser verification for coding agents: Chrome DevTools MCP vs agent-browser]]></title><description><![CDATA[This post is about browser feedback for coding agents during normal development work, not about fully autonomous browser agents or end-to-end testing as such.]]></description><link>https://www.huuhka.net/browser-verification-for-coding-agents-chrome-devtools-mcp-vs-agent-browser/</link><guid isPermaLink="false">69d7ca2c313b720001df3d9e</guid><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[GitHub Copilot]]></category><category><![CDATA[OpenCode]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Wed, 28 Jan 2026 17:13:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/91615_Browser%20verification%20showdown_%20Chrome%20vs%20Agent.png" medium="image"/><content:encoded><![CDATA[<img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/91615_Browser%20verification%20showdown_%20Chrome%20vs%20Agent.png" alt="Browser verification for coding agents: Chrome DevTools MCP vs agent-browser"><p>I&apos;ve been treating browser-side verification as a standard part of the implementation loop when working with coding agents for a while now.</p><p>Frontend work is still one of the places where current models make a steady stream of mistakes. CSS is often wrong in small but obvious ways, interaction states get missed, responsive behavior regresses, and the model will happily tell you everything is fine unless you give it some way to actually look at the result.</p><p>This post is about browser feedback for coding agents during normal development work, not about fully autonomous browser agents or end-to-end testing as such.</p><p>None of this is really tied to one harness either. The same general ideas work with basically any coding agent setup that can expose MCP servers or CLI tools, whether that is <a href="https://opencode.ai/?ref=huuhka.net">OpenCode</a>, <a href="https://github.com/features/copilot?ref=huuhka.net">GitHub Copilot</a>, <a href="https://code.claude.com/docs/en/overview?ref=huuhka.net">Claude Code</a>, <a href="https://developers.openai.com/codex?ref=huuhka.net">Codex</a> or something else.</p><p>The two tools I&apos;ve been using most are <a href="https://github.com/ChromeDevTools/chrome-devtools-mcp?ref=huuhka.net">Chrome DevTools MCP</a> and <a href="https://github.com/vercel-labs/agent-browser?ref=huuhka.net">agent-browser</a>.</p><h3 id="the-short-version">The short version</h3><ul><li>I use both, but at the time of writing I still reach for Chrome DevTools MCP more.</li><li><strong>Model familiarity:</strong> current models seem to understand the Chrome DevTools MCP tool surface better than the agent-browser CLI.</li><li><strong>Session isolation:</strong> agent-browser feels more naturally aligned with isolated per-agent work, because it is a CLI with explicit session handling. Chrome DevTools MCP has improved here too with named isolated contexts, so the difference is more about workflow shape than absence of support.</li><li><strong>Debugging depth:</strong> Chrome DevTools MCP gives a richer debugging surface, especially for console, network, performance and general inspection.</li><li><strong>Delivery model:</strong> in <a href="https://opencode.ai/?ref=huuhka.net">OpenCode</a>, MCP is always present in the model context, while the <a href="https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser?ref=huuhka.net">agent-browser skill</a> can be loaded only when needed.</li><li><strong>Parallel work friction:</strong> multiple agents trying to use Chrome DevTools MCP in parallel can easily end up fighting for browser control.</li><li>These tools are very useful because current models are still pretty bad at frontend correctness if you only let them reason from code.</li></ul><h3 id="why-i-keep-adding-browser-verification-into-the-loop">Why I keep adding browser verification into the loop</h3><p>I&apos;ve written earlier about <a href="https://www.huuhka.net/research-plan-implement/">Research - Plan - Implement</a>, <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/">Primary vs Subagents in LLM harnesses</a> and <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/">A mental model for LLM tooling primitives</a>.</p><p>The browser tooling question sits underneath all of those.</p><p>If I have an implementation agent that can edit code, run tests and lint, that is already useful. But especially for UI work, there is still a pretty big gap between &quot;the code compiles&quot; and &quot;the feature is actually correct&quot;.</p><p>That gap is exactly where browser tools help. They let the model inspect what really rendered, what requests fired, what errors showed up in the console, whether the element is actually visible, and whether the flow works beyond static code review. In practice that gives a much better feedback loop than just asking the model to look at JSX or CSS and hope for the best.</p><h3 id="two-different-design-bets">Two different design bets</h3><p>These two tools are aimed at slightly different shapes of work.<br><a href="https://github.com/vercel-labs/agent-browser?ref=huuhka.net">agent-browser</a> is a CLI-first browser automation tool. The workflow is command-driven, and in coding harnesses you can expose it through the <a href="https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser?ref=huuhka.net">agent-browser skill</a> when actually needed.</p><p><a href="https://github.com/ChromeDevTools/chrome-devtools-mcp?ref=huuhka.net">Chrome DevTools MCP</a> is an MCP server. The browser capabilities are available as a tool surface directly through the harness.</p><p>That sounds like a small implementation detail, but it really does affect how the tools feel in daily use. With agent-browser, the capability is more opt-in. With Chrome DevTools MCP, the capability is more ambient. That has obvious pros and cons.</p><h3 id="chrome-devtools-mcp-rich-debugging-awkward-sharing">Chrome DevTools MCP: rich debugging, awkward sharing</h3><p>The biggest reason I still reach for Chrome DevTools MCP more often is simple: it gives a very strong debugging surface.</p><p>You get browser automation, but also:</p><ul><li>Console inspection</li><li>Network inspection</li><li>Screenshots and snapshots</li><li>Performance tracing</li><li>Lighthouse audits</li><li>Memory snapshots</li></ul><p>That makes it more than a &quot;click around in the page&quot; tool. It is closer to handing the model a browser plus a chunk of DevTools itself.</p><p>Current models also seem to know how to use this MCP surface better than they know how to use agent-browser. That is not a scientific benchmark, just my practical impression after using both. The models seem more ready to do reasonable things with page selection, snapshots, console logs and network requests than they are to drive a CLI workflow correctly from scratch.</p><p>The downside has mostly shown up when I try to push more parallel agent work through it: Agents are not particularly good at sharing Chrome DevTools MCP sanely across concurrent work. If I have multiple agents or parallel workstreams trying to use the same browser tooling, it starts feeling like a tug of war over the active page or session. That may partly be a prompting issue on my side, but in practice it has meant that browser verification works better when I centralize it to one orchestrator or one review pass instead of letting every parallel worker poke at the same browser.</p><p>So:</p><p>Chrome DevTools MCP is very strong for <strong>one active agent</strong> doing deep verification and debugging.- It is less comfortable as a <strong>shared browser layer </strong>for multiple concurrent agents.</p><blockquote><strong>Update 9.4.2026:</strong> I went back and checked this more carefully. <a href="https://github.com/ChromeDevTools/chrome-devtools-mcp?ref=huuhka.net">Chrome DevTools MCP</a> added storage-isolated browser contexts in <a href="https://github.com/ChromeDevTools/chrome-devtools-mcp/releases/tag/chrome-devtools-mcp-v0.18.0?ref=huuhka.net">v0.18.0</a> via <code>isolatedContext</code> on <code>new_page</code>, and then added page routing for parallel multi-agent workflows in <a href="https://github.com/ChromeDevTools/chrome-devtools-mcp/releases/tag/chrome-devtools-mcp-v0.19.0?ref=huuhka.net">v0.19.0</a>. So the multi-agent story is better than I originally thought, though in practice it still depends on what your harness actually exposes and how well the model uses it.</blockquote><p>So this is not really a case of agent-browser having sessions and Chrome DevTools MCP not having them. Chrome DevTools MCP does now have named isolated contexts. The difference is more that agent-browser has a more explicit workflow around sessions, saved state, auth reuse and diffs, whereas Chrome DevTools MCP is stronger as a live inspection and diagnostics surface.</p><h3 id="agent-browser-explicit-control-natural-session-separation">agent-browser: explicit control, natural session separation</h3><p>What I like about agent-browser is that the whole thing feels more explicit.<br>It is a CLI tool with concrete commands, explicit sessions, state save/load, snapshots, screenshots, console inspection, request tracking, diffing and a few other useful pieces. It also has a clear <a href="https://github.com/vercel-labs/agent-browser/tree/main/skills/agent-browser?ref=huuhka.net">skill package</a> that teaches the model the recommended workflow when needed.</p><p>The on-demand skill aspect is worth highlighting.</p><p>One of the common problems with MCP servers in general is that they can take a fair amount of context all the time, whether or not the task really needs them. Skills are a more selective mechanism. The model only expands the instructions when it actually decides it needs that capability. The deeper difference is less &quot;skill vs MCP&quot; and more ambient tool surface versus explicit workflow tool.</p><p>The other thing I like is that agent-browser feels more naturally suited to isolated sessions. That makes it probably the better shape for future parallel verification flows where individual agents verify their own work without all trying to grab the same browser handle.</p><p>It also feels more like a reusable automation utility. Things like saved state, auth reuse, diffing, and provider support make it easier to imagine as a repeatable browser worker in a larger workflow.</p><p>Headless support is part of this story too. Both tools can run headless, but the shape is different. With agent-browser, headless or headed operation is a natural part of the CLI workflow. One practical pattern I&apos;ve been using is to open a headed session first, do whatever auth setup is needed there, and then reuse that state for the agent&apos;s headless sessions afterward. Chrome DevTools MCP does support headless mode as well, but that is more of a server launch or browser configuration detail than part of the agent&apos;s normal workflow.</p><p>The main weakness right now is not the tool itself. Current models do not seem deeply fluent with it yet.</p><p>The agent-browser skill is fairly extensive, and that helps, but it also makes very obvious that models are not coming in with much native familiarity. Quite often they fumble around with the CLI a bit before landing on the right command sequence. There does not seem to be much training data here yet. That will likely improve. Right now though, it is still visible.</p><p>One thing I also checked more carefully here is whether agent-browser exposes comparable network and performance inspection to Chrome DevTools MCP.The short answer is: partially, but not really at the same depth.</p><p>It does expose <code>network requests</code>, <code>trace</code>, and <code>profiler</code> commands, and I verified that the profiling and trace capture do work in practice. But it still feels more like request monitoring plus trace capture than full DevTools-style inspection. Chrome DevTools MCP is stronger here because it exposes explicit tools for things like listing network requests, fetching a specific request, saving request or response bodies, Lighthouse audits, and performance-insight style workflows.</p><p>So I would not describe the network or performance inspection story as equivalent today.</p><h3 id="rough-feature-comparison">Rough feature comparison</h3><p>These are two different design bets, not a case of one tool simply being better.</p><figure class="kg-card kg-image-card kg-width-wide kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/91612_image.png" class="kg-image" alt="Browser verification for coding agents: Chrome DevTools MCP vs agent-browser" loading="lazy" width="3472" height="618"><figcaption><span style="white-space: pre-wrap;">Wish I had support for tables...</span></figcaption></figure><h3 id="screenshot-handling-in-opencode">Screenshot handling in OpenCode</h3><p>One very practical issue I have hit with Chrome DevTools tooling in OpenCode is screenshot handling.</p><p>Sometimes when the model takes a screenshot, the image payload ends up flooding the session context, the whole session can fall over pretty quickly. I&apos;ve had this happen enough times that it is worth calling out explicitly.</p><p>The workaround is simple but useful: tell the model to save the screenshot to a file first, then read the file afterward if needed.</p><p>That sounds minor, but it is exactly the kind of operational detail that matters once these tools become part of the normal workflow.</p><h3 id="beyond-visual-verification">Beyond visual verification</h3><p>Most of my own use has been around visual verification, but the useful scope is wider than that.</p><p>Some examples:</p><ul><li><strong>Console errors:</strong> checking whether a new feature introduced them</li><li><strong>Network requests:</strong> catching failed API calls after a UI change</li><li><strong>Auth and session state:</strong> verifying redirects, cookies, local storage</li><li><strong>Bug reproduction:</strong> issues that are easier to see in the browser than in code</li><li><strong>Responsive / dark mode:</strong> quick testing without manual switching</li><li><strong>Artifacts:</strong> capturing screenshots, traces or request logs for later review</li><li><strong>Adversarial review:</strong> letting a review agent try to break the implementation</li><li><strong>Interaction flow:</strong> validating the actual flow works, not just the static layout</li></ul><p>Sometimes the page looks fine in a screenshot, but the real problem is a broken loading state, disabled button, wrong request payload, client-side error or auth/session issue. Browser tooling is useful exactly because it can move between visual inspection and behavioral debugging instead of forcing you to pick one.</p><h3 id="browser-tooling-fits-review-agents-well">Browser tooling fits review agents well</h3><p>One thing I&apos;ve liked is handing browser tooling to a more adversarial review agent after implementation.<br>That review pass can be asked to inspect the implemented page visually, check console and network errors, validate a specific flow end to end and try edge cases the implementation agent may have skipped.</p><p>That tends to work well because the review agent is not attached to the implementation it just wrote. It is looking for mismatches instead of trying to defend its own earlier reasoning. For frontend work in particular, that extra pass has felt useful, but is a useful tool for any coding tasks. The cost you pay is of course the time it takes for the extra model to think.</p><h3 id="open-questions">Open questions</h3><p>A few things I have not fully resolved yet.</p><p><strong>Who should own browser verification?</strong> The implementation agent, a higher-level orchestrator, or a separate reviewer? I currently lean toward orchestrator or reviewer, especially if concurrent agents are involved, but if you set up shared auth, I&apos;m sure both options can work. Similarily <strong>Session isolation for parallel agents </strong>tackles a bit of the similar problem space. If every agent shares one browser, you get contention. If every agent gets its own clean and controllable browser state, a lot of the workflow becomes simpler and they get faster feedback to fix their own issues quickly. </p><p><strong>Security and state handling.</strong> The moment these tools start storing auth state, cookies, local storage or remote debugging connections, there is a real security conversation to have.</p><p><strong>Performance and debugging, not just CSS.</strong> It would be easy to accidentally frame these as only visual verification tools. That would undersell Chrome DevTools MCP especially, since a lot of its value is in debugging and performance analysis.</p><h3 id="playwright-mcp">Playwright MCP</h3><p>Another tool in this space is <a href="https://github.com/microsoft/playwright-mcp?ref=huuhka.net">Playwright MCP</a>. I have not used it myself yet, so I won&apos;t pretend I have a strong opinion.<br>Interestingly, its own README makes a distinction between MCP-based workflows and CLI + skill based workflows for coding agents, which is very much the same design space as the tradeoff discussed above. Worth evaluating.</p><h3 id="final-thought">Final thought</h3><p>Giving coding agents some way to verify browser state is increasingly worth it, because current models are still nowhere near reliable enough to just &quot;reason the UI correctly&quot; from code alone.</p>]]></content:encoded></item><item><title><![CDATA[Securing remote MCP servers with Entra ID without breaking reconnects]]></title><description><![CDATA[I've been wiring remote MCP servers behind Entra-protected endpoints lately, and the awkward part isn't really validating a JWT, but everything around it.]]></description><link>https://www.huuhka.net/securing-remote-mcp-servers-with-entra-id-without-breaking-reconnects/</link><guid isPermaLink="false">69e3bd3d6f3c900001bdae6f</guid><category><![CDATA[AI]]></category><category><![CDATA[OpenCode]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Fri, 16 Jan 2026 18:31:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/181731_ruan-richard-rodrigues-sT1sEvYUjwU-unsplash.jpg" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Secure Enterprise AI Tooling On Azure theme:<br>- <a href="https://www.huuhka.net/securing-remote-mcp-servers-with-entra-id-without-breaking-reconnects/" rel="noreferrer">Securing remote MCP servers with Entra ID without breaking reconnects</a><br>- <a href="https://www.huuhka.net/preserving-mcp-session-continuity-with-redis/" rel="noreferrer">Preserving MCP session continuity with Redis</a><br>- <a href="https://www.huuhka.net/shipping-signed-config-updates-to-local-ai-tooling/" rel="noreferrer">Shipping signed config updates to local AI tooling</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/181731_ruan-richard-rodrigues-sT1sEvYUjwU-unsplash.jpg" alt="Securing remote MCP servers with Entra ID without breaking reconnects"><p>I&apos;ve been wiring remote MCP servers behind Entra-protected endpoints lately, and the awkward part isn&apos;t really validating a JWT, but everything around it.</p><p>Most MCP server implementations don&apos;t come with Entra ID support out of the box. In the AI platform I&apos;ve been building, every service sits behind a shared APIM gateway that requires an Entra bearer token. That includes the MCP servers. The way I handle this is by running an Entra-authenticating reverse proxy in front of the upstream MCP server, so the server itself doesn&apos;t need to know anything about Entra at all. The proxy validates the caller&apos;s token, and then forwards the request upstream.</p><p>That moves the authentication story out of the individual MCP server code and into a shared layer that&apos;s consistent across the platform. But it also means that every local client needs to be able to acquire the right token, attach it to every outbound request, and handle the usual lifecycle problems: expiry, 401 retries, and reconnects after session loss.</p><p>On the client side, I&apos;m using <a href="https://opencode.ai/?ref=huuhka.net">OpenCode</a> as the coding tool. OpenCode has a plugin system that lets you intercept outbound HTTP requests, inject headers, and react to lifecycle events. I ended up building a set of plugins that handle all of this transparently, so the developers using the platform don&apos;t have to think about auth at all. It just works in the background.</p><h3 id="setup">Setup</h3><p>The shape of the setup is fairly simple:</p><ul><li>Local OpenCode plugins acquire Entra tokens and attach them to the right outbound requests</li><li>APIM and a reverse proxy sit in front of the remote MCP servers</li><li>The proxy validates the bearer token and forwards traffic to the upstream MCP server</li><li>The MCP server itself stays unaware of Entra-specific concerns</li></ul><p>That split has worked well for me. The authentication behavior stays consistent across services, and the MCP server implementation can stay focused on MCP instead of identity plumbing.</p><h3 id="request-flow">Request flow</h3><p>At a high level, the request path is straightforward. The local client requests an Entra token for the configured audience, and an OpenCode plugin attaches that token to the outbound MCP HTTP request. APIM and the reverse proxy validate the token and forward the request upstream. If the token is stale or the session is lost, the client can refresh the token and try the transport recovery path.</p><h3 id="its-just-http">It&apos;s just HTTP</h3><p>MCP servers aren&apos;t especially exotic from a security point of view. They&apos;re HTTP servers with some long-lived connection behavior. You still need to authenticate callers, validate tokens, and make sure the transport can reconnect when things go wrong.</p><p>Once the transport is remote, most of the difficulty is just protected HTTP plumbing with some session continuity concerns on top. In practice, the solution space is much more normal than the surrounding discussion sometimes suggests.</p><p>What I ended up with was one shared token acquisition path on the client side, narrow bearer injection for the intended remote endpoints, service-side validation that accepts the issuer and audience variants I know I&apos;ll see in practice, and a reconnect model that can heal dropped transport sessions.</p><h3 id="shared-token-acquisition">Shared token acquisition</h3><p>The first practical lesson was that token acquisition shouldn&apos;t be reinvented separately by every plugin.</p><p>I have several OpenCode plugins that need to acquire Entra tokens. One handles auth for the MCP servers behind the platform proxy. Another handles auth for the LLM provider calls routed through the same APIM gateway. A third handles auth for an enterprise session sharing service. It just made sense to consolidate the token acquisition logic into one shared module.</p><p>So I built one shared auth module with a very plain order of operations:</p><ol><li>Return a fresh cached token if one exists.</li><li>Try <code>DefaultAzureCredential</code>.</li><li>If that fails, fall back to <code>az account get-access-token</code>.</li><li>If the CLI explicitly needs login, do a controlled login flow and retry once.</li></ol><p>All of this lives in a shared library that every plugin imports, so the behavior is identical everywhere.</p><p>Because this runs inside OpenCode&apos;s plugin system, I could also integrate the login flow into the UI itself. If a user needs to sign in, the plugin shows a toast notification guiding them through the process instead of just leaving them with a failed auth error and a hint about running `az login` on their own. Toasts only work on the TUI version though, so it&apos;s not a perfect solution. However, this was good enough for even the non-technical users to be logged in and productive without needing to understand the Az CLI at all.</p><p>This setup also gave me the two things I cared about. Non-interactive environments had a good chance of succeeding through identity-based auth, and local developer machines still had a reliable escape hatch through the CLI.</p><p>The other useful detail was separating silent acquisition from interactive login. A plugin that&apos;s running during startup should be allowed to try to get a token quietly. It shouldn&apos;t immediately decide to throw a browser sign-in flow at the user before the UI is even properly ready.</p><h3 id="audience-resolution">Audience resolution</h3><p>Once token acquisition is shared, the next thing that matters is that all paths agree on what audience is being requested.</p><p>That sounds trivial until you support both custom API audiences and Azure resource audiences. At that point you need the identity path and the CLI path to resolve the target exactly the same way, or you end up debugging 401s that are really just inconsistencies in your own local tooling.</p><p>The shape is simple enough:</p><pre><code class="language-ts">export type AuthConfig = {
  tenant?: string;
  clientId?: string;
  resource?: string;
};

export function resolveAudience(config: AuthConfig) {
  if (config.clientId) {
    const scope = `api://${config.clientId}/.default`;
    return {
      kind: &quot;scope&quot; as const,
      scope,
      cliArgs: [&quot;--scope&quot;, scope] as const,
    };
  }

  if (config.resource) {
    const resource = config.resource.replace(/\/+$/, &quot;&quot;);
    return {
      kind: &quot;resource&quot; as const,
      scope: `${resource}/.default`,
      cliArgs: [&quot;--resource&quot;, resource] as const,
    };
  }

  throw new Error(&quot;Missing clientId or resource&quot;);
}</code></pre><p>It&apos;s important that every caller in the local tooling ends up behaving consistently. The MCP auth plugin, the provider auth plugin, and the enterprise share plugin all go through the same resolution logic. If one of them gets the scope wrong, the token won&apos;t match what the service expects, and the result is a <code>401</code> that looks like a service-side problem but is really a local misconfiguration.</p><h3 id="attaching-tokens">Attaching tokens</h3><p>Once the client can acquire the right token, the next question is where it should use it.</p><p>I&apos;d avoid broad global rules here. In the OpenCode plugins, each one intercepts <code>fetch</code> and matches outbound requests against explicitly configured URL prefixes. The MCP auth plugin knows which URLs correspond to remote MCP servers behind the platform proxy. The provider auth plugin knows which providers route through the gateway. Each plugin only injects a bearer token for its own scope. That way you don&apos;t accidentally start attaching tokens to random outbound requests that shouldn&apos;t have them, and you keep the auth behavior focused on the intended paths.</p><p>The other useful behavior was to treat <code>401</code> as a signal to evict the cached token and retry once. Without that, you can end up reusing a bad token until it expires. With too much retry logic, you just create more noise. One forced refresh and one retry is a decent middle ground.</p><h3 id="issuer-compatibility">Issuer compatibility</h3><p>It&apos;s very tempting to validate only the Entra v2 issuer because that&apos;s the one you had in mind when configuring the app registration. In practice, that can be wrong.<br>What pushed me into this in the first place was seeing legitimate callers arrive with the older v1 issuer format. In my environment that showed up with managed identity and gateway-mediated paths, which was enough to make strict v2-only validation a problem.</p><p>Microsoft&apos;s own Entra token validation guidance is explicit here: Microsoft Entra-issued access tokens can use either <code>https://sts.windows.net/{tenant-id}</code> for v1.0 tokens or <code>https://login.microsoftonline.com/{tenant-id}/v2.0</code> for v2.0 tokens. So this isn&apos;t some oddity specific to MCP. It&apos;s just something your API validation layer needs to account for if valid callers in your environment can receive both token versions.</p><p>The same thing happens with audiences. One token may present the bare client ID. Another may use the application ID URI form. So the practical rule became to accept the variants that legitimate callers in my environment can actually produce:</p><pre><code class="language-ts">export function buildValidIssuers(tenantId: string): string[] {
  return [
    `https://login.microsoftonline.com/${tenantId}/v2.0`,
    `https://sts.windows.net/${tenantId}/`,
  ];
}

export function buildValidAudiences(clientId: string): string[] {
  return [clientId, `api://${clientId}`];
}</code></pre><h3 id="proxy-and-reconnects">Proxy and reconnects</h3><p>Once auth is working, the problem is still only half solved.</p><p>The reverse proxy that fronts the upstream MCP servers needs to behave like decent transport plumbing. It shouldn&apos;t buffer SSE responses. It should use an upstream HTTP version the server actually supports. It should expose a simple health route for probes instead of forcing those probes into your auth story.<br>After that, the reconnect behavior becomes the interesting part.</p><p>What I liked in the implementation here was that it didn&apos;t rely on a single recovery mechanism. In the MCP auth plugin, there&apos;s a lighter health reconnect tick that periodically checks whether targets are still connected. There&apos;s a slower hard reconnect tick that fully resets sessions on a longer cadence. And on top of that, request-time behavior can recover a target when the transport starts showing session loss symptoms, like a 404 with a session-not-found error body.</p><p>That felt realistic. Long-lived authenticated transport tends to fail in a few different ways, and it&apos;s useful to have more than one way back to a healthy state.<br>The cooldown logic matters too. If several concurrent requests all decide that they should reconnect the same remote MCP target at once, you&apos;ve just created a new kind of problem for yourself. The plugin deduplicates recovery attempts per target and applies a cooldown window so that one burst of session-not-found responses doesn&apos;t turn into a reconnect storm.</p><p>This is also where telemetry earns its keep. If the proxy can log and trace events like accepted sessions, possible session loss, retries, and timeouts after session acceptance, you stop guessing quite so much.</p><p>One thing worth calling out separately is that transport reconnects and cross-process session continuity are related, but not identical, problems. Reconnect logic helps the client recover when an existing transport drops. It does not by itself make server-side session state survive a restart or rollout. I&apos;ll cover that continuity side separately in a follow-up post using Redis.</p><h3 id="wrap-up">Wrap up</h3><p>The main lesson for me was that remote MCP security isn&apos;t mostly about MCP. It&apos;s about consistency.</p><p>Consistent token acquisition. Consistent audience resolution. Consistent issuer handling. Consistent proxy behavior when the connection gets interrupted. Once those pieces line up, remote MCP feels much less special and much more like what it really is: another authenticated infrastructure path with slightly more session sensitivity than average.</p><p>In my case, the fact that the MCP servers themselves don&apos;t know anything about Entra is a feature, not a limitation. The proxy handles auth, the OpenCode plugins handle token lifecycle, and the developers using the platform don&apos;t have to care about any of it.</p><p>The solution isn&apos;t to invent some MCP-specific security model. It&apos;s to do the identity and transport work properly.</p><p>If you&apos;re building something similar, the practical checklist I&apos;d keep in mind is:</p><ul><li>Centralize token acquisition</li><li>Resolve audiences consistently across every local auth path</li><li>Accept the issuer and audience variants your real callers can legitimately produce</li><li>Keep bearer injection narrow and explicit</li><li>Treat reconnect behavior and cross-restart session continuity as separate design problems<br></li></ul>]]></content:encoded></item><item><title><![CDATA[Primary vs Subagents in LLM harnesses]]></title><description><![CDATA[I’ve been refining my own mental model for agent splits in coding workflows, and this post is a snapshot of what has worked best so far.]]></description><link>https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/</link><guid isPermaLink="false">69a446a8dae91c00012b5c98</guid><category><![CDATA[AI]]></category><category><![CDATA[OpenCode]]></category><category><![CDATA[GitHub Copilot]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Thu, 15 Jan 2026 14:35:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11435_11435_image.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Agentic Dev theme:<br>- <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/" rel="noreferrer">A mental model for LLM tooling primitives</a><br>- <a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">Research - Plan - Implement</a><br>- <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/" rel="noreferrer">Primary vs Subagents in LLM harnesses</a><br>- <a href="https://www.huuhka.net/how-i-currently-develop-with-llm-models-early-2026/" rel="noreferrer">How I currently develop with LLM models (Early 2026)</a> <br>- <a href="https://www.huuhka.net/building-your-own-pr-reviewer-with-coding-agents/" rel="noreferrer">Building your own PR reviewer with coding agents</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11435_11435_image.png" alt="Primary vs Subagents in LLM harnesses"><p>I&#x2019;ve been refining my own mental model for agent splits in coding workflows, and this post is a snapshot of what has worked best so far.</p><p>A lot of this aligns with ideas from Dex Horthy&#x2019;s talk from the AI Engineer Code Summit (<a href="https://www.youtube.com/watch?v=rmvDxxNubIg&amp;ref=huuhka.net" rel="noreferrer">YouTube</a>) and with the way I&#x2019;ve been structuring Research-Plan-Implement flows (<a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">my post here</a>).</p><p>I also touched adjacent concepts in <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/" rel="noreferrer">A mental model for LLM tooling primitives</a>.</p><h3 id="the-short-version">The short version</h3><ul><li>Primary agents are user-facing orchestrators.</li><li>Subagents are context protectors and scoped executors.</li><li>If a split doesn&#x2019;t reduce context pressure, it&#x2019;s probably not a useful split.</li></ul><p>That&#x2019;s really it.</p><h2 id="what-primary-agents-should-do">What primary agents should do</h2><p><strong>Primary agents </strong>should be the only layer directly responsible for user interaction and overall task flow.</p><p>They define:</p><ul><li><strong>how</strong> the session should behave,</li><li><strong>what</strong> kind of <strong>output</strong> should be returned,</li><li><strong>when to delegate</strong> work,</li><li>and <strong>how</strong> to synthesize results into a final answer.</li></ul><p>In practice, primary agents should mostly orchestrate and synthesize. They can do work themselves too, but for bigger tasks their main value is coordination.</p><p>A useful framing: primary agent = <strong>director</strong>, not <strong>every actor</strong> on stage. (Though as always, there are cases where it can do everything. Depends on the size of the task you&apos;re currently working on)</p><h2 id="what-subagents-should-do">What subagents should do</h2><p><strong>Subagents</strong> should exist primarily to <strong>protect the context window </strong>of the primary agent.</p><p><strong>Not</strong> &#x201C;security agent&#x201D;, &#x201C;SRE agent&#x201D;, &#x201C;backend agent&#x201D; by default.<br><strong>Instead:</strong> small, narrow units of work that return distilled outcomes.</p><p><strong>Good subagent jobs:</strong></p><ul><li>locate files relevant to a topic,</li><li>analyze a specific code path,</li><li>find existing patterns,</li><li>implement one atomic change set,</li><li>summarize prior research/decisions.</li></ul><p>What they return should usually be <strong>compact</strong> and <strong>structured</strong>. This could be a short summary, file paths + line pointers, key findings / decisions or explicit gaps. So not full file dumps unless absolutely necessary.</p><figure class="kg-card kg-image-card"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11435_image.png" class="kg-image" alt="Primary vs Subagents in LLM harnesses" loading="lazy" width="1890" height="1515"></figure><h3 id="common-failure-mode-i-keep-seeing">Common failure mode I keep seeing</h3><p>Sometimes the primary agent asks subagents for too much, like: &#x201C;return full contents of the files&#x201D;, &#x201C;return all changes in detail&#x201D; or &#x201C;paste everything you found&#x201D;</p><p>That defeats the whole point of the split and wastes money and time as well, so it&apos;s important to catch when it happens.</p><p>The solution is to <strong>fix the primary prompt</strong> so subagents return distillations instead. If the primary needs full content, it can read targeted files itself afterward.</p><h3 id="parallelism-is-not-optional">Parallelism is not optional</h3><p>If subagent tasks are independent, the primary should spawn them in parallel.</p><p>Typical good parallel batches:</p><ul><li>multiple locators on different search angles,</li><li>independent implementation tasks touching disjoint files,</li><li>separate analyzers for code, patterns, and prior notes.</li></ul><p>This is usually the easiest way to reduce wall-clock time without losing quality. The primary agent is often smart enough to make these decisions, but the risk exists that there is some underlying dependency that can affect the coherency of the final result.</p><h3 id="share-plan-artifacts-explicitly">Share plan artifacts explicitly</h3><p>Subagents do not magically inherit full context from the primary.<br>So when there is a plan/research artifact, instruct the primary agent to pass the link/path directly in the subagent prompt.</p><p>This is exactly why I like writing explicit plan files in the first place (again: <a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">Research-Plan-Implement</a>): they become stable handoff artifacts between agent hops.</p><p>Without that handoff, primaries often assume subagents &#x201C;know the whole story&#x201D;, and that assumption breaks quickly. The subagents can then read the plan to get the full context (e.g. what are we doing? What has already been done so far?). If your plans aren&apos;t massive, this should be fine from the context perspective.</p><h3 id="a-practical-contract-i-like-for-subagent-responses">A practical contract I like for subagent responses</h3><p>I haven&apos;t spent enough time on tuning these yet myself, but something like this could be used as a guideline when designing what the subagents return. Often the primary agent&apos;s prompt gives the instructions anyway, so it feels more powerful to tune that instead.</p><ul><li><strong>Result</strong>: 3&#x2013;7 bullets max</li><li><strong>Evidence</strong>: path:line references</li><li><strong>State</strong>: done / partial / blocked</li><li><strong>Next input needed</strong>: one short line (if blocked)</li></ul><h3 id="implementation-examples">Implementation examples</h3><p>Here are a few of the subagents I&apos;m using. Mostly taken from the humanlayer repository.</p><ul><li><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/subagents/research/codebase-locator.md?ref=huuhka.net" rel="noreferrer">codebase-locator</a><br>Finds where things live, grouped by purpose. No deep analysis.</li><li><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/subagents/research/codebase-analyzer.md?ref=huuhka.net" rel="noreferrer">codebase-analyzer</a><br>Explains how specific flows work with precise file:line references.</li><li><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/subagents/research/pattern-finder.md?ref=huuhka.net" rel="noreferrer">pattern-finder</a><br>Finds established implementation/test patterns to mirror.</li></ul><h2 id="final-thoughts">Final thoughts</h2><p>For me, primary/subagent design is mostly a context engineering problem, not a role taxonomy problem.</p><p>If your primary agent is drowning in tokens, make subagents narrower.<br>If your subagents are returning novels, tighten response contracts.<br>If execution is slow, parallelize independent work.<br>If context gets lost, pass explicit plan files.</p><p>Everything else is details.</p>]]></content:encoded></item><item><title><![CDATA[Automating Azure DevOps workload identity service connections end to end]]></title><description><![CDATA[How to automate the whole flow and get rid of passwords at the same time]]></description><link>https://www.huuhka.net/automating-azure-devops-workload-identity-service-connections-end-to-end/</link><guid isPermaLink="false">69de7770b92fba00013b621b</guid><category><![CDATA[Bicep]]></category><category><![CDATA[PowerShell]]></category><category><![CDATA[DevOps]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Tue, 13 Jan 2026 18:28:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141730_brice-cooper-uRnWbdawNLo-unsplash.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/4/141730_brice-cooper-uRnWbdawNLo-unsplash.jpg" alt="Automating Azure DevOps workload identity service connections end to end"><p>One of the more annoying setup tasks in Azure DevOps has been service connections. It&apos;s not necessarily difficult, but the workload identity federation flow crosses Azure and Azure DevOps in exactly the wrong place.</p><p>You can create the managed identity in Azure. You can create the service connection in Azure DevOps. But the awkward values you need in order to finish the federated credential only show up once Azure DevOps has done its part. So the whole thing ends up as a mildly clumsy roundtrip between two control planes. Sounds like a perfect candidate for automation.</p><p>I have written before about using user-assigned managed identities behind Azure DevOps service connections in <a href="https://www.huuhka.net/user-assigned-managed-identities-with-azure-devops-service-connections/">User Assigned Managed Identities with Azure DevOps Service Connections</a>. That post was more about why I like that identity model and what the basic manual setup looks like. This post is really the follow-up to that. The interesting bit here is not the identity choice itself, but how to automate the whole roundtrip once the service connection starts generating federation details on the Azure DevOps side.</p><p>If I&apos;m bootstrapping a new workload, I want the identity, the service connection, the federation details and the follow-up permissions to come out of a rerunnable setup process. I don&apos;t want somebody clicking through a half-manual draft flow and pasting values around just because the platform boundary is inconvenient.</p><h3 id="manual-pain">Manual = Pain</h3><p>The manual version of this isn&apos;t exactly hard. It&apos;s just awkward enough to survive for far too long.</p><p>If you want the basic setup flow, the earlier post covers it well enough and I won&apos;t repeat all of it here. The short version is that you create or pick a user-assigned managed identity, create the Azure Resource Manager service connection in Azure DevOps using workload identity federation, and then finish the trust relationship on the Azure side once you have the federation details. I still like user-assigned identities for this because they let you manage the lifecycle from the Azure side without having to depend on App Registration access in Entra ID, which is often a very real constraint in customer tenants.</p><p>After that setup is complete, you still need to do the actual useful work: grant RBAC on the target resource groups, maybe grant some shared platform permissions, and save enough state that reruns don&apos;t become guesswork.<br>The other problem with the manual flow is partial state. It&apos;s very easy to end up with a managed identity that exists, a service connection that exists, and a missing federated credential in between. Nothing is fully broken, but the setup isn&apos;t actually finished either. Or as often happens, you&apos;re creating these for multiple environments at the same time, and you end up mixing up the federation details between them. The risk of human error is pretty high here.</p><h3 id="why-the-roundtrip-exists">Why the roundtrip exists</h3><p>The awkward bit is that the federated credential lives on the Azure side, but the values you need for it are effectively materialized by Azure DevOps once the service connection exists. That is the part that changes the character of the problem a bit compared to the earlier manual setup post. Previously it was possible to calculate these values in advance and avoid the roundtrip, but now the `subject` is opaque enough that you really want to read it from Azure DevOps instead of trying to outsmart the platform.</p><p>You need one pass to create or resolve the managed identity, then a pass through Azure DevOps to create the service connection, then one more pass back into Azure to attach the federated credential using the <code>issuer</code> and <code>subject</code> that Azure DevOps exposes.</p><p>The setup loop was basically this:</p><p>Azure creates or resolves the identity. Azure DevOps creates the service connection that points at that identity. Azure DevOps refreshes the endpoint so the federation details are visible. Azure attaches the federated credential. Only after that do the permission-granting steps happen. Arguably you COULD do this in Bicep alone with deployment scripts, but those take forever to provision and tear down, so I wanted to keep the orchestration logic in PowerShell where it&apos;s more nimble (not to mention the pain if you&apos;re limited to network integrated resources only).</p><p>I found it useful to keep those as separate concerns. Creating a usable service connection and granting that principal the permissions it needs are related, but they&apos;re not the same step. Splitting them made the rerun behavior easier to reason about too.</p><h3 id="the-azure-devops-part">The Azure DevOps part</h3><p>I ended up letting Azure DevOps create the endpoint first and then explicitly query it for the federation values.</p><p>In practice that meant creating the service connection with the managed identity client ID, calling the endpoint refresh API, and then reading back the workload identity issuer and subject from the endpoint data. That was the slightly backwards part of the whole flow, but it&apos;s also the part that made the automation reliable.</p><p>The core PowerShell shape isn&apos;t especially complicated. The create call is really just constructing the AzureRM endpoint payload with workload identity federation and the managed identity client ID:</p><pre><code class="language-powershell">function New-AdoAzureRmFederatedServiceConnection {
    param(
        [string]$Organization,
        [string]$Project,
        [string]$ServiceConnectionName,
        [string]$TenantId,
        [string]$SubscriptionId,
        [string]$SubscriptionName,
        [string]$ManagedIdentityClientId,
        [string]$AccessToken,
        [string]$ProjectId = &apos;00000000-0000-0000-0000-000000000000&apos;
    )

    $body = @{
        authorization = @{
            scheme     = &apos;WorkloadIdentityFederation&apos;
            parameters = @{
                serviceprincipalid = $ManagedIdentityClientId
                tenantid           = $TenantId
            }
        }
        data = @{
            environment      = &apos;AzureCloud&apos;
            scopeLevel       = &apos;Subscription&apos;
            creationMode     = &apos;Manual&apos;
            subscriptionId   = $SubscriptionId
            subscriptionName = $SubscriptionName
        }
        name        = $ServiceConnectionName
        type        = &apos;AzureRM&apos;
        url         = &apos;https://management.azure.com/&apos;
        owner       = &apos;library&apos;
        isShared    = $false
        isReady     = $false
        serviceEndpointProjectReferences = @(
            @{
                name             = $ServiceConnectionName
                projectReference = @{
                    id   = $ProjectId
                    name = $Project
                }
            }
        )
    }

    Invoke-RestMethod -Method Post -Uri &quot;https://dev.azure.com/$Organization/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4&quot; -Headers @{
        Authorization = &quot;Bearer $AccessToken&quot;
    } -ContentType &apos;application/json&apos; -Body ($body | ConvertTo-Json -Depth 20)
}</code></pre><pre><code class="language-powershell">function Get-AdoServiceConnectionFederationDetails {
    param(
        [string]$Organization,
        [string]$Project,
        [string]$ServiceConnectionId,
        [string]$AccessToken
    )

    $refreshBody = @(
        @{
            endpointId             = $ServiceConnectionId
            tokenValidityInMinutes = 5
        }
    )

    $result = Invoke-RestMethod -Method Post -Uri &quot;https://dev.azure.com/$Organization/$Project/_apis/serviceendpoint/endpoints?endpointIds=$ServiceConnectionId&amp;api-version=7.1&quot; -Headers @{
        Authorization = &quot;Bearer $AccessToken&quot;
    } -ContentType &apos;application/json&apos; -Body ($refreshBody | ConvertTo-Json -Depth 10)

    $endpoint = @($result.value)[0]

    return [ordered]@{
        issuer  = [string]$endpoint.authorization.parameters.workloadIdentityFederationIssuer
        subject = [string]$endpoint.authorization.parameters.workloadIdentityFederationSubject
    }
}

$endpoint = New-AdoAzureRmFederatedServiceConnection `
    -Organization &apos;example-org&apos; `
    -Project &apos;example-project&apos; `
    -ServiceConnectionName &apos;example-release-dev&apos; `
    -TenantId $tenantId `
    -SubscriptionId $subscriptionId `
    -SubscriptionName $subscriptionName `
    -ManagedIdentityClientId $managedIdentityClientId `
    -AccessToken $adoToken

$federation = Get-AdoServiceConnectionFederationDetails `
    -Organization &apos;example-org&apos; `
    -Project &apos;example-project&apos; `
    -ServiceConnectionId $endpoint.id `
    -AccessToken $adoToken</code></pre><p>The sequencing is the important part. I would&apos;ve happily avoided the refresh step if the platform had made the values available earlier, but once you accept the roundtrip, the flow is stable enough.</p><h3 id="the-azure-side">The Azure side</h3><p>On the Bicep side I wanted one module that could support both passes.<br>The first run needs to be able to create or resolve the user-assigned managed identity without requiring federation values yet. The second run needs to take the same identity and attach the federated credential once <code>issuer</code> and <code>subject</code> are known.</p><p>That meant the module needed to support both creating a new identity and targeting an existing one. The practical shape I liked was to make the federated credential conditional on both <code>issuer</code> and <code>subject</code> being present, and then attach it either to the newly created identity or to an existing one.</p><pre><code class="language-bicep">param location string
param identityName string
param createIdentity bool = true
param issuer string = &apos;&apos;
param subject string = &apos;&apos;

var shouldAttachFederation = !empty(issuer) &amp;&amp; !empty(subject)

resource identity &apos;Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31&apos; = if (createIdentity) {
  name: identityName
  location: location
}

resource existingIdentity &apos;Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31&apos; existing = if (!createIdentity) {
  name: identityName
}

resource federatedCredentialForNewIdentity &apos;Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31&apos; = if (createIdentity &amp;&amp; shouldAttachFederation) {
  parent: identity
  name: &apos;AzureDevOps&apos;
  properties: {
    issuer: issuer
    subject: subject
    audiences: [
      &apos;api://AzureADTokenExchange&apos;
    ]
  }
}

resource federatedCredentialForExistingIdentity &apos;Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31&apos; = if (!createIdentity &amp;&amp; shouldAttachFederation) {
  parent: existingIdentity
  name: &apos;AzureDevOps&apos;
  properties: {
    issuer: issuer
    subject: subject
    audiences: [
      &apos;api://AzureADTokenExchange&apos;
    ]
  }
}</code></pre><p>That audience value is not especially interesting, but it is one of those details that I prefer to keep explicit in the module rather than relying on people to remember it later.</p><p>This kept the orchestration simple. The PowerShell can run the same module twice with different inputs instead of having to reason about two different deployment shapes.</p><h3 id="safety-checks">Safety Checks</h3><p>Once the full loop was automated, the more important question became how strict the automation should be. For me the main danger wasn&apos;t reuse by itself. Reuse is often exactly what you want. The dangerous case is when a service connection with the expected name already exists but points to a different managed identity than the one your automation just created or resolved.</p><p>So the behavior I wanted was simple. If the service connection doesn&apos;t exist, create it. If it exists and points at the expected managed identity, reuse it. If it exists and points somewhere else, stop immediately.</p><p>The other practical thing that mattered was checkpointing outputs after each successful environment. If `dev` succeeded and `prod` failed, I wanted to keep the saved client ID, principal ID, service connection ID, issuer and subject from the successful side. That made reruns much less irritating. In fact, I currently do this state management for many of our platform services, as it makes idempotency much easier to achieve.</p><h3 id="end-result">End result</h3><p>The obvious improvement is that there are fewer clicks, but that&apos;s honestly the least interesting part. What actually got better was that the setup became deterministic. The trust relationship no longer depended on somebody manually copying values between Azure DevOps and Azure. The naming stayed consistent. The outputs were saved. The follow-up permission steps had stable inputs. And when something failed, the failure mode was much easier to understand.</p><p>It also forced a cleaner mental model. There&apos;s a service connection creation loop, and there&apos;s a permission grant loop. They feed into each other, but they&apos;re not the same thing.</p><h3 id="closing-thoughts">Closing thoughts</h3><p>Workload identity federation for Azure DevOps service connections isn&apos;t hard in the normal sense. It&apos;s just awkward at the point where product boundaries meet.<br>Once I stopped fighting that and explicitly automated the roundtrip, the whole thing became much more boring &#x2014; which is exactly what I wanted.</p><p>The setup now creates or resolves the identity, creates or reuses the service connection, reads the federation details back from Azure DevOps, finishes the federated credential on the Azure side, and only then continues to the authorization work.</p><p>Pretty simple and practical, just the way I like it.</p>]]></content:encoded></item><item><title><![CDATA[Connecting OpenCode with Microsoft Foundry Models]]></title><description><![CDATA[In this post I'll show you how I've configured the providers to my own Foundry, which hosts both the recently announced Anthropic models as well as models by OpenAI and others.]]></description><link>https://www.huuhka.net/connecting-opencode-with-microsoft-foundry-models/</link><guid isPermaLink="false">69a31460deaf9d0001d85482</guid><category><![CDATA[AI]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[Azure]]></category><category><![CDATA[Microsoft Foundry]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Sun, 21 Dec 2025 16:41:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/2/281641_opencode_plus_azure_ai_foundry.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is a part of a larger AI Dev Platform theme:<br>- <a href="https://www.huuhka.net/ai-dev-platform-fundamentals/" rel="noreferrer">Azure AI Dev Platform Fundamentals</a><br>- <a href="https://www.huuhka.net/practical-experiences-with-azure-apim-ai-gateway-and-imported-foundry-endpoints/" rel="noreferrer">Practical experiences with Azure APIM AI Gateway and imported Foundry endpoints</a><br>- <a href="https://www.huuhka.net/designing-a-shared-opentelemetry-contract-for-ai-services-on-azure/" rel="noreferrer">Designing a shared OpenTelemetry contract for AI services on Azure</a><br>- <a href="https://www.huuhka.net/connecting-opencode-with-microsoft-foundry-models/" rel="noreferrer">Connecting OpenCode with Microsoft Foundry Models</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/2/281641_opencode_plus_azure_ai_foundry.png" alt="Connecting OpenCode with Microsoft Foundry Models"><p>I&apos;ve been using <a href="https://opencode.ai/docs/?ref=huuhka.net" rel="noreferrer">OpenCode</a> as my coding agent of choice for a quite while now. It&apos;s great that I can use both GitHub Copilot subscription and my own Foundry models with it, and swap between them with a single keybinding.</p><p>In this post I&apos;ll show you how I&apos;ve configured the providers to my own Foundry, which hosts both the recently announced Anthropic models as well as models by OpenAI and others. This does not directly conform to the official way of configuring them described <a href="https://opencode.ai/docs/providers/?ref=huuhka.net#azure-openai" rel="noreferrer">here</a> and <a href="https://opencode.ai/docs/providers/?ref=huuhka.net#azure-cognitive-services" rel="noreferrer">here</a> in the docs, but it does work and is arguably simpler.</p><p>I expect that you already have deployments of the models running in foundry, but if not, you can cobble them up with something like this (note that Anthropic only works on pay as you go subs):</p><pre><code class="language-bicep">// params.bicep
param anthropicDeployments = [
  {
    deploymentName: &apos;claude-sonnet-4-5&apos;
    modelName: &apos;claude-sonnet-4-5&apos;
    version: &apos;20250929&apos;
    sku: {
      name: &apos;GlobalStandard&apos;
      capacity: 450
    }
    format: &apos;Anthropic&apos;
    thinking: true
  }
  {
    deploymentName: &apos;claude-opus-4-5&apos;
    modelName: &apos;claude-opus-4-5&apos;
    version: &apos;20251101&apos;
    sku: {
      name: &apos;GlobalStandard&apos;
      capacity: 450
    }
    format: &apos;Anthropic&apos;
    thinking: true
  }
  {
    deploymentName: &apos;claude-haiku-4-5&apos;
    modelName: &apos;claude-haiku-4-5&apos;
    version: &apos;20251001&apos;
    sku: {
      name: &apos;GlobalStandard&apos;
      capacity: 450
    }
    format: &apos;Anthropic&apos;
    thinking: false
  }
]

param openAiDeployments = [
  {
    deploymentName: &apos;gpt-5.2&apos;
    modelName: &apos;gpt-5.2&apos;
    version: &apos;2025-12-11&apos;
    sku: {
      name: &apos;GlobalStandard&apos;
      capacity: 50
    }
    format: &apos;OpenAI&apos;
    thinking: true
  }
  {
    deploymentName: &apos;gpt-5.1-codex-max&apos;
    modelName: &apos;gpt-5.1-codex-max&apos;
    version: &apos;2025-12-04&apos;
    sku: {
      name: &apos;GlobalStandard&apos;
      capacity: 200
    }
    format: &apos;OpenAI&apos;
    thinking: true
  }
]</code></pre><pre><code class="language-bicep">var deployments = concat(anthropicDeployments, openAiDeployments)

@batchSize(1) // Runs into conflict if run in parallel
resource model_deployments &apos;Microsoft.CognitiveServices/accounts/deployments@2025-10-01-preview&apos; = [
  for deployment in (deployModels ? deployments : []): {
    parent: foundry
    name: deployment.deploymentName
    sku: deployment.sku
    properties: {
      model: {
        name: deployment.modelName
        version: deployment.version
        format: deployment.format
      }
      #disable-next-line BCP037
      modelProviderdata: deployment.format == &apos;Anthropic&apos;
        ? {
            countryCode: tenant().countryCode
            industry: &apos;consulting&apos;
            organizationName: tenant().displayName
          }
        : null
      #disable-next-line BCP073 // The api version thinks this is a read only value
      dynamicThrottlingEnabled: deployment.sku.name == &apos;GlobalStandard&apos; ? false : true
      versionUpgradeOption: &apos;OnceCurrentVersionExpired&apos;
    }
  }
]</code></pre><p>You&apos;ll also need the api key to the Foundry, as OpenCode does not yet support oauth to Foundry directly (though you can write a plugin). </p><p><strong>The OpenCode config</strong></p><p>You could also generate this config directly from the bicep outputs if you&apos;d want. I&apos;ll leave that up to you. Here are examples of how I have it set up.</p><pre><code class="language-json">// ~/.local/share/opencode/auth.json
{
  &quot;azure-anthropic&quot;: {
    &quot;type&quot;: &quot;api&quot;,
    &quot;key&quot;: &quot;KEYVALUE&quot;
  },
  &quot;azure-openai&quot;: {
    &quot;type&quot;: &quot;api&quot;,
    &quot;key&quot;: &quot;KEYVALUE&quot;
  },
  &quot;github-copilot&quot;: {
    ....
  }
}</code></pre><pre><code>// ~/.config/opencode/opencode.json(c)
{
  &quot;$schema&quot;: &quot;https://opencode.ai/config.json&quot;,
  &quot;provider&quot;: {
    &quot;azure-anthropic&quot;: {
      &quot;name&quot;: &quot;Foundry (Anthropic)&quot;,
      &quot;npm&quot;: &quot;@ai-sdk/anthropic&quot;,
      &quot;api&quot;: &quot;https://somefoundry.services.ai.azure.com/anthropic/v1&quot;,
      &quot;models&quot;: {
        &quot;claude-sonnet-4-5&quot;: {
          &quot;id&quot;: &quot;claude-sonnet-4-5&quot;,
          &quot;name&quot;: &quot;claude-sonnet-4-5&quot;,
          &quot;tool_call&quot;: true,
          &quot;attachment&quot;: true,
          &quot;reasoning&quot;: true,
          &quot;temperature&quot;: true,
          &quot;modalities&quot;: {
            &quot;input&quot;: [&quot;text&quot;, &quot;image&quot;],
            &quot;output&quot;: [&quot;text&quot;]
          }
        },
        &quot;claude-opus-4-5&quot;: {
          &quot;id&quot;: &quot;claude-opus-4-5&quot;,
          &quot;name&quot;: &quot;claude-opus-4-5&quot;,
          &quot;tool_call&quot;: true,
          &quot;attachment&quot;: true,
          &quot;reasoning&quot;: true,
          &quot;temperature&quot;: true,
          &quot;modalities&quot;: {
            &quot;input&quot;: [&quot;text&quot;, &quot;image&quot;],
            &quot;output&quot;: [&quot;text&quot;]
          }
        },
        &quot;claude-haiku-4-5&quot;: {
          &quot;id&quot;: &quot;claude-haiku-4-5&quot;,
          &quot;name&quot;: &quot;claude-haiku-4-5&quot;,
          &quot;tool_call&quot;: true,
          &quot;attachment&quot;: true,
          &quot;reasoning&quot;: false,
          &quot;temperature&quot;: true,
          &quot;modalities&quot;: {
            &quot;input&quot;: [&quot;text&quot;, &quot;image&quot;],
            &quot;output&quot;: [&quot;text&quot;]
          }
        }
      }
    },
    &quot;azure-openai&quot;: {
      &quot;name&quot;: &quot;Foundry (OpenAI)&quot;,
      &quot;npm&quot;: &quot;@ai-sdk/openai&quot;,
      &quot;api&quot;: &quot;https://somefoundry.services.ai.azure.com/openai/v1&quot;,
      &quot;models&quot;: {
        &quot;gpt-5.1-codex-max&quot;: {
          &quot;id&quot;: &quot;gpt-5.1-codex-max&quot;,
          &quot;name&quot;: &quot;gpt-5.1-codex-max&quot;,
          &quot;tool_call&quot;: true,
          &quot;attachment&quot;: true,
          &quot;reasoning&quot;: true,
          &quot;temperature&quot;: true,
          &quot;modalities&quot;: {
            &quot;input&quot;: [&quot;text&quot;, &quot;image&quot;],
            &quot;output&quot;: [&quot;text&quot;]
          }
        },
        &quot;gpt-5.2&quot;: {
          &quot;id&quot;: &quot;gpt-5.2&quot;,
          &quot;name&quot;: &quot;gpt-5.2&quot;,
          &quot;tool_call&quot;: true,
          &quot;attachment&quot;: true,
          &quot;reasoning&quot;: true,
          &quot;temperature&quot;: true,
          &quot;modalities&quot;: {
            &quot;input&quot;: [&quot;text&quot;, &quot;image&quot;],
            &quot;output&quot;: [&quot;text&quot;]
          }
        }
      }
    }
  }
}
</code></pre><p>Aaand it should just work. Enjoy!</p>]]></content:encoded></item><item><title><![CDATA[Research - Plan - Implement]]></title><description><![CDATA[Core ideas and GH + OpenCode implementations of the Research - Plan - Implement development flow]]></description><link>https://www.huuhka.net/research-plan-implement/</link><guid isPermaLink="false">69a40b94deaf9d0001d854bd</guid><category><![CDATA[AI]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[OpenCode]]></category><dc:creator><![CDATA[Pasi Huuhka]]></dc:creator><pubDate>Wed, 17 Dec 2025 11:48:00 GMT</pubDate><media:content url="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11032_11032_image.png" medium="image"/><content:encoded><![CDATA[<div class="kg-card kg-callout-card kg-callout-card-yellow"><div class="kg-callout-emoji">&#x1F195;</div><div class="kg-callout-text">Update: Added GH Copilot versions of the agents in the repo, though these haven&apos;t seen much real world usage</div></div><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">&#x1F4A1;</div><div class="kg-callout-text">This post is part of a larger Agentic Dev theme:<br>- <a href="https://www.huuhka.net/a-mental-model-for-llm-tooling-primitives/" rel="noreferrer">A mental model for LLM tooling primitives</a><br>- <a href="https://www.huuhka.net/research-plan-implement/" rel="noreferrer">Research - Plan - Implement</a><br>- <a href="https://www.huuhka.net/primary-vs-subagents-in-llm-harnesses/" rel="noreferrer">Primary vs Subagents in LLM harnesses</a><br>- <a href="https://www.huuhka.net/how-i-currently-develop-with-llm-models-early-2026/" rel="noreferrer">How I currently develop with LLM models (Early 2026)</a> <br>- <a href="https://www.huuhka.net/building-your-own-pr-reviewer-with-coding-agents/" rel="noreferrer">Building your own PR reviewer with coding agents</a></div></div><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11032_11032_image.png" alt="Research - Plan - Implement"><p>Many of the LLM harness creators have been experimenting with &quot;spec driven development&quot; flows lately. Some examples for this are <a href="https://github.com/github/spec-kit?ref=huuhka.net" rel="noreferrer">GitHub&apos;s Spec-kit</a>, Fission-AI&apos;s <a href="https://github.com/Fission-AI/OpenSpec?ref=huuhka.net" rel="noreferrer">OpenSpec</a>, and <a href="https://github.com/humanlayer/humanlayer?ref=huuhka.net" rel="noreferrer">Humanlayer&apos;s</a> Research Plan Implement flow.</p><p>I&apos;ve been testing all of these and while they all work well, I&apos;ve found that the RPI flow works best for my workflows. It&apos;s simple enough, is easy to implement in any tool and easy to scale depending on the complexity of the task you&apos;re working on. Included in this post are examples for implementing this flow in both OpenCode and GitHub Copilot.I</p><p><strong>The core idea</strong> with all of these is to protect the context of the models, and avoiding going over 40-60% of the total context window to stay in the &quot;smart zone&quot; for all the work you&apos;re doing. In practice this is done via saving the state in a markdown file in between the steps.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://huuhkadotnet.blob.core.windows.net/prod/images/2026/3/11032_image.png" class="kg-image" alt="Research - Plan - Implement" loading="lazy" width="1899" height="1068"><figcaption><span style="white-space: pre-wrap;">Core idea</span></figcaption></figure><p>This flow <strong>scales</strong> both <strong>up </strong>and <strong>down</strong>. </p><ul><li>For <strong>small tasks,</strong> I often skip the full ceremony and just talk directly with the coding agent.</li><li>For <strong>medium tasks</strong>, one research file + one plan is usually enough. </li><li>For <strong>large or messy work</strong>, I split the effort into multiple research docs and multiple plans (by domain, service, or milestone) so context stays focused and decisions stay traceable. </li></ul><p>The key is not to be dogmatic: do the least structure needed to reliably reach the result you want. Start lightweight, add process only when complexity or risk justifies it, and keep adjusting based on what actually works for your way of building.</p><h2 id="my-agent-stack">My Agent Stack</h2><p>The main agents I use are just named research, plan and implement. The implementer can be just any normal coding agent. The point of this flow is to get the plan ready so the implementation can start.</p><p>This works for every harness that supports custom agents (most of them do). My implementation is mainly for <a href="https://opencode.ai/?ref=huuhka.net" rel="noreferrer">OpenCode</a>, but you can ask your LLM to translate these into any tool of your choice very easily. I basically just took the prompts from the HumanLayer repos, and modified them to meet my needs.</p><h3 id="the-three-agents">The three agents</h3><p>I keep this intentionally simple: one agent for research, one for planning, and one for implementation.</p><p><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/research-humanlayer.md?ref=huuhka.net" rel="noreferrer"><strong>Research Agent</strong></a><strong> (</strong><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.github/agents/hl-research.agent.md?ref=huuhka.net" rel="noreferrer"><strong>Copilot example</strong></a><strong>)</strong><br>The research agent&#x2019;s only job is to understand and document the current state of the codebase. Not &#x201C;fix,&#x201D; not &#x201C;improve,&#x201D; not &#x201C;rewrite&#x201D; - just map what exists.</p><p>It looks for relevant files, traces how things currently work, and writes the findings into a research markdown file. That becomes a stable handoff artifact for the next stage.</p><p>In my mind the main point here is to distill hundreds, thousands, maybe a hundred thousand lines of code into a very compact form describing where the next phase should actually read the most important info from.</p><p><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/plan-humanlayer.md?ref=huuhka.net" rel="noreferrer"><strong>Plan Agent</strong></a><strong> (</strong><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.github/agents/hl-plan.agent.md?ref=huuhka.net" rel="noreferrer"><strong>Copilot Example</strong></a><strong>)</strong><br>The plan agent turns research into a concrete implementation plan with phases and checkpoints.</p><p>Its role is to reduce ambiguity before coding starts:</p><ul><li>what files are expected to change</li><li>what is explicitly out of scope</li><li>what &#x201C;done&#x201D; means for each phase</li><li>what should be verified before continuing</li></ul><p>The output is a plan file that the implementation phase can execute directly.<br>At this point, implementation should feel like execution, not exploration. </p><p>The agent is guided to ask any open questions from the user. Sometimes this does need some extra nudging to make it happen, but it&apos;s important that you actually read the plan and discuss with the agent to clarify the actual implementation and also understand yourself what the feature actually needs to do. It&apos;s much cheaper to get the details right at this point than tuning after the implementation.</p><p><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/agent/implementer.md?ref=huuhka.net" rel="noreferrer"><strong>Implement Agent</strong></a><strong> (</strong><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.github/agents/hl-implement.agent.md?ref=huuhka.net" rel="noreferrer"><strong>Copilot Example</strong></a><strong>)</strong><br>The implement agent executes the approved plan phase by phase.<br>It is optimized for disciplined delivery:</p><ul><li>follow the plan</li><li>make targeted changes</li><li>run checks</li><li>surface mismatches between &#x201C;plan vs reality&#x201D; quickly</li></ul><p>If reality differs from the plan, the goal is to adapt while preserving intent, not freestyle a new design in the middle of coding.</p><p>In other words, this agent is for shipping, not for deciding architecture on the fly. However, like I mentioned earlier you could replace this part with whatever you want.</p><p><strong>About the Slash Commands</strong><br>You don&#x2019;t actually need slash commands for this flow.<br>I use them because they are convenient routing shortcuts:</p><ul><li><a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/command/research.md?ref=huuhka.net" rel="noreferrer">/research</a> ... -&gt; sends the prompt to the research agent</li><li>/<a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/command/plan.md?ref=huuhka.net" rel="noreferrer">plan</a> ... -&gt; sends the prompt to the plan agent</li><li>/<a href="https://github.com/DrBushyTop/humanlayer-opencode/blob/master/.opencode/command/implement.md?ref=huuhka.net" rel="noreferrer">implement</a> ... -&gt; sends the prompt to the implement agent</li></ul><p>That&#x2019;s mostly it. They&#x2019;re ergonomic wrappers around prompt dispatch, not a magical requirement.</p><p>If your tool can target agents directly, you can run the same workflow without slash commands at all. The repo has some other examples for handoff, iteration and oneshotting implementations, but I&apos;ve not really experimented with the usefulness of those, as opencode tends to do the compaction step itself, which quite closely matches the handoff logic.</p><h3 id="quick-note-about-the-repo">Quick Note About the Repo</h3><p>I&#x2019;m linking the repo mainly as a reference for people who want to peek at how this is wired.</p><p>It is not really packaged for public consumption or polished as a &#x201C;drop-in product.&#x201D;</p><p>Still, if you&#x2019;re curious, you can browse it, copy ideas, and adapt the structure to your own harness/tooling setup. The concepts are portable even if the exact implementation is opinionated.</p>]]></content:encoded></item></channel></rss>