<?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:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Serif Colakel on Medium]]></title>
        <description><![CDATA[Stories by Serif Colakel on Medium]]></description>
        <link>https://medium.com/@serifcolakel?source=rss-d6c4531665d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*Xu08L9dEskTOGHBuK8BvuQ.png</url>
            <title>Stories by Serif Colakel on Medium</title>
            <link>https://medium.com/@serifcolakel?source=rss-d6c4531665d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 04:26:59 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@serifcolakel/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Durable Execution for AI Agents: State, Checkpointing, Retries and Human-in-the-Loop]]></title>
            <link>https://medium.com/@serifcolakel/durable-execution-for-ai-agents-state-checkpointing-retries-and-human-in-the-loop-bb87a8da3a5b?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/bb87a8da3a5b</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[react]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 19:07:10 GMT</pubDate>
            <atom:updated>2026-11-03T19:07:10.616Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bGDiCRmRyHqnSWEb_gFv8Q.png" /><figcaption>Durable Execution for AI Agents: State, Checkpointing, Retries and Human-in-the-Loop Image</figcaption></figure><p><em>How to build AI agents that survive failures, resume interrupted tasks, handle approvals, and execute reliably in production.</em></p><h3>1. Why Do AI Agents Need Durable Execution?</h3><p>Most AI applications start with a relatively straightforward execution model.</p><p>A user sends a message, the application invokes an LLM, the model may call a tool, and the application returns a response.</p><p>For simple interactions, this is usually sufficient.</p><p>However, agentic applications introduce a different level of complexity.</p><p>An agent may execute several model calls, invoke multiple tools, delegate tasks to other agents, wait for external systems, and require human approval before continuing.</p><p>Consider an agent responsible for reviewing and processing a business request:</p><ol><li>The user submits a request.</li><li>The agent analyzes the request.</li><li>It retrieves information from internal systems.</li><li>It delegates an analysis task to a specialist agent.</li><li>It generates a proposed action.</li><li>It requests approval from an authorized user.</li><li>After approval, it executes the operation.</li><li>It records the result and completes the task.</li></ol><p>This workflow may take seconds, minutes, or even several hours.</p><p>Now imagine the application crashes after step five.</p><p>What happens to the work already completed?</p><p>Does the agent start over? Are previous model calls executed again? Will the user need to submit the request again? Could an external operation be executed twice?</p><p>These are not unusual edge cases. They are fundamental engineering problems in long-running AI systems.</p><p>A conventional in-memory agent generally keeps its execution state inside the current process. When that process terminates unexpectedly, the state may be lost.</p><p>Saving conversation messages to a database does not solve this problem by itself.</p><p><strong>Durable Execution is the ability to preserve workflow progress and resume execution from a known point after failures, interruptions, or external events.</strong></p><p>It allows us to build agents that are not dependent on one uninterrupted application process.</p><p>The objective is not merely to make an agent remember what happened. It is to make the entire execution reliable.</p><h3>2. State Persistence vs. Durable Execution</h3><p>These concepts are related, but they are not interchangeable.</p><h3>State Persistence</h3><p>State persistence means storing information so that it can be retrieved later.</p><p>For an AI application, this might include:</p><ul><li>Conversation history</li><li>User preferences</li><li>Current task status</li><li>Agent configuration</li><li>Intermediate results</li><li>Retrieved documents</li><li>Tool execution results</li></ul><p>For example, we can store conversation messages in PostgreSQL and load them when a user returns to the application.</p><p>This provides conversation continuity.</p><p>However, storing messages does not automatically tell us where an interrupted workflow should resume.</p><h3>Durable Execution</h3><p>Durable execution goes further.</p><p>It records the execution progress and the results of completed steps so that the workflow can continue without repeating all previous work.</p><p>Consider this example:</p><pre>Agent Workflow<br>      |<br>      v<br>Retrieve Context<br>      |<br>      v<br>LLM Analysis<br>      |<br>      v<br>Execute Tool<br>      |<br>      v<br>Generate Report<br>      |<br>      X Application Crashes</pre><p>Without durable execution, the application may need to restart the entire workflow.</p><p>With checkpointing:</p><pre>Agent Workflow<br>      |<br>      v<br>Retrieve Context       ✓ Checkpoint<br>      |<br>      v<br>LLM Analysis           ✓ Checkpoint<br>      |<br>      v<br>Execute Tool           ✓ Checkpoint<br>      |<br>      v<br>Generate Report        X Failed<br>      |<br>      v<br>Application Restarts<br>      |<br>      v<br>Resume from last<br>completed checkpoint<br>      |<br>      v<br>Generate Report<br>      |<br>      v<br>Complete</pre><p>The completed results can be reused while the failed operation is retried.</p><p>This distinction becomes particularly important when model calls are expensive or when tools perform operations against external systems.</p><h3>What about Agent Memory?</h3><p>Memory and durable execution are also different concepts.</p><p>ConceptResponsibilityConversation historyWhat the user and agent discussedShort-term memoryInformation needed during the current interactionLong-term memoryInformation retained across interactionsCheckpointingWhere execution stopped and what has completedDurable executionHow the workflow resumes and continues reliably</p><p>For example, an agent may remember a user’s preferences through long-term memory while a durable workflow maintains the execution state of an ongoing approval process.</p><p>One does not replace the other.</p><p><strong>Reference:</strong> <a href="https://docs.langchain.com/oss/javascript/langgraph/persistence">LangGraph — Persistence</a></p><h3>3. Core Concepts Behind Durable Execution</h3><p>Before choosing a framework, it helps to understand the underlying engineering primitives.</p><h3>3.1. Checkpointing</h3><p>A checkpoint is a persisted representation of workflow state at a specific point in execution.</p><p>Depending on the runtime, it may include:</p><ul><li>Completed step identifiers</li><li>Step outputs</li><li>Workflow state</li><li>Execution metadata</li><li>Pending operations</li><li>Current task status</li><li>References to external payloads</li></ul><p>When a workflow resumes, the runtime uses these checkpoints to determine which operations have already completed.</p><p>Checkpointing is particularly useful when a workflow contains expensive or long-running operations.</p><p>However, we should avoid storing unnecessarily large objects in every checkpoint.</p><p>For example, instead of persisting a 50 MB document inside workflow state, we can store it in object storage and keep only its identifier and metadata in the checkpoint.</p><h3>3.2. Replay</h3><p>Replay is the process of reconstructing workflow execution using its previously recorded history.</p><p>Some durable execution engines replay workflow code to rebuild the current execution state.</p><p>This introduces an important engineering requirement: deterministic execution.</p><p>If the same workflow is replayed with the same history, it should make the same workflow decisions.</p><p>Consider:</p><pre>async function workflow() {<br>  const timestamp = Date.now();<br><br>  // Continue execution...<br>}</pre><p>If the workflow is replayed later, Date.now() returns a different value.</p><p>This could change its execution path.</p><p>Likewise, direct network calls, random values, and database queries should not be performed inside deterministic workflow definitions when the runtime expects replay-safe code.</p><p>These operations should be isolated into activities or durable steps.</p><p>For example:</p><pre>async function workflow() {<br>  const timestamp = await getCurrentTimeStep();<br><br>  // Continue using the recorded result...<br>}</pre><p>The durable runtime records the step result and reuses it during replay.</p><p>This is a foundational concept in systems such as Temporal.</p><h3>3.3. Durable Steps</h3><p>A durable workflow is usually divided into independent execution units.</p><p>For example:</p><pre>Workflow<br>   |<br>   +-- Step 1: Retrieve documents<br>   |<br>   +-- Step 2: Generate analysis<br>   |<br>   +-- Step 3: Validate output<br>   |<br>   +-- Step 4: Request approval<br>   |<br>   +-- Step 5: Execute operation<br>   |<br>   +-- Step 6: Save final result</pre><p>Each completed step produces a checkpoint.</p><p>If step five fails, the workflow should not need to repeat document retrieval, model analysis, and validation.</p><p>This also allows us to apply different retry policies to different operations.</p><h3>3.4. Durable Waits</h3><p>A workflow may need to wait for something that is outside the application’s control.</p><p>Examples include:</p><ul><li>Human approval</li><li>An external webhook</li><li>A scheduled execution time</li><li>A payment confirmation</li><li>A response from another agent</li><li>Completion of a long-running background operation</li></ul><p>A regular asynchronous function can wait for an event, but keeping the process alive indefinitely is not a scalable approach.</p><p>Durable execution systems can suspend the workflow, persist its state, and resume it when the expected event arrives.</p><p>The application does not need to keep a worker occupied during the waiting period.</p><h3>3.5. Retries and Recovery</h3><p>Failures should be handled according to their type.</p><p>A network timeout, provider rate limit, validation error, and authorization failure should not necessarily follow the same retry policy.</p><p>A retry policy should define:</p><ul><li>Maximum attempts</li><li>Initial delay</li><li>Backoff strategy</li><li>Maximum delay</li><li>Retryable error categories</li><li>Overall execution deadline</li><li>Cancellation behavior</li></ul><p>A durable execution engine provides the infrastructure for retries, but the application still needs to define what should be retried and why.</p><h3>4. The Most Important Concept: Idempotency</h3><p>This is one of the most important aspects of building reliable AI agents.</p><p>Durable execution can resume workflows and retry failed steps. But retries can introduce duplicate side effects.</p><p>Consider an agent that generates a report and publishes it to an external system.</p><p>The operation succeeds, but the application crashes before the workflow records the result.</p><p>When execution resumes, the workflow may attempt to publish the report again.</p><p>Without additional protection, we could create two reports.</p><p>This is why idempotency matters.</p><p><strong>An operation is idempotent when executing it multiple times with the same logical input produces the same intended outcome as executing it once.</strong></p><p>For example, consider a report publishing API:</p><pre>async function publishReport(input: {<br>  reportId: string;<br>  content: string;<br>  operationId: string;<br>}) {<br>  const response = await fetch(<br>    &#39;https://api.example.com/reports&#39;,<br>    {<br>      method: &#39;POST&#39;,<br>      headers: {<br>        &#39;Content-Type&#39;: &#39;application/json&#39;,<br>        &#39;Idempotency-Key&#39;: input.operationId,<br>      },<br>      body: JSON.stringify({<br>        reportId: input.reportId,<br>        content: input.content,<br>      }),<br>    },<br>  );<br><br>  if (!response.ok) {<br>    throw new Error(<br>      `Report publishing failed: ${response.status}`,<br>    );<br>  }<br>  return response.json();<br>}</pre><p>The important detail is operationId.</p><p>It must be generated and persisted by a trusted part of the application before the external operation is attempted. It should not be an arbitrary identifier generated by the LLM.</p><p>The receiving service must also implement idempotency-key handling. Merely sending the header does not guarantee duplicate prevention.</p><h3>Why isn’t exactly-once execution automatic?</h3><p>This is a common misunderstanding.</p><p>A workflow engine can guarantee that a successfully checkpointed step is not unnecessarily executed again during replay.</p><p>However, an external side effect and the workflow’s checkpoint cannot always participate in the same atomic transaction.</p><p>There is a failure window:</p><ol><li>An external API accepts the request.</li><li>The external operation succeeds.</li><li>The application crashes.</li><li>The workflow has not recorded the successful result.</li><li>The workflow retries.</li></ol><p>This means we should not assume that every external operation executes exactly once.</p><p>A more accurate approach is to design for:</p><ul><li>At-least-once execution attempts.</li><li>Idempotent external operations.</li><li>Durable operation identifiers.</li><li>Deduplication at the receiving service.</li><li>Transactional outbox patterns where appropriate.</li><li>Reconciliation for operations whose result is uncertain.</li></ul><p>For critical business operations, this distinction is non-negotiable.</p><p>A durable workflow is not a substitute for transactional business design.</p><h3>5. Human-in-the-Loop: When an Agent Needs Approval</h3><p>Not every operation should be executed automatically.</p><p>In enterprise applications, an agent may need authorization before performing an operation that has financial, operational, or security implications.</p><p>For example:</p><ul><li>Publishing a report</li><li>Updating customer information</li><li>Executing a database modification</li><li>Approving a business transaction</li><li>Changing production configuration</li><li>Triggering an external workflow</li></ul><p>A traditional implementation may look like this:</p><pre>Agent<br>  |<br>  v<br>Generate Proposed Action<br>  |<br>  v<br>Save Pending Approval<br>  |<br>  v<br>Notify User<br>  |<br>  v<br>Wait for Response<br>  |<br>  v<br>User Approves<br>  |<br>  v<br>Continue Agent</pre><p>The problem is that the application needs to preserve enough state to resume the original execution.</p><p>A durable workflow makes this much easier.</p><p>It can:</p><ol><li>Execute the agent until an approval boundary.</li><li>Persist the current state.</li><li>Suspend execution.</li><li>Notify the appropriate user.</li><li>Wait for an approval event.</li><li>Resume from the saved state.</li><li>Continue or abort based on the decision.</li></ol><p>The approval process may take seconds, hours, or days.</p><p>Importantly, the application should verify the identity and authorization of the person responding. The decision should be bound to the original operation, its parameters, and its expiration policy.</p><p>An approval should not be a generic boolean that can be reused for another action.</p><h3>6. Implementing Durable Agents with Vercel AI SDK</h3><p>For TypeScript-based applications, the Vercel AI SDK ecosystem provides an interesting integration through WorkflowAgent.</p><p>The standard ToolLoopAgent runs its execution loop in memory.</p><p>This is suitable for short interactions, but its progress can be lost when the underlying process terminates.</p><p>WorkflowAgent, provided through @ai-sdk/workflow, runs the agent inside the Vercel Workflow runtime.</p><p>It provides:</p><ul><li>Persistent execution progress</li><li>Independent tool retries</li><li>Recovery after process interruptions</li><li>Human approval that can survive long waits</li><li>Workflow-level execution visibility</li><li>Resumable chat streaming through WorkflowChatTransport</li></ul><p>An important distinction: durability comes from the Workflow runtime, not simply from changing the agent class.</p><h3>Step 1: Install the packages</h3><pre>pnpm add ai @ai-sdk/workflow workflow zod</pre><h3>Step 2: Define durable tool operations</h3><p>A tool operation can be marked as a durable step using the &#39;use step&#39; directive.</p><pre>async function searchDocumentsStep(input: {<br>  query: string;<br>}) {<br>  &#39;use step&#39;;<br>  const response = await fetch(<br>    `https://api.example.com/search?q=${encodeURIComponent(<br>      input.query,<br>    )}`,<br>  );<br>  if (!response.ok) {<br>    throw new Error(<br>      `Document search failed: ${response.status}`,<br>    );<br>  }<br>  return response.json();<br>}</pre><p>The runtime can persist the result of this step and retry it independently when a retryable failure occurs.</p><p>This is particularly useful when the operation interacts with an unreliable external service.</p><h3>Step 3: Create a WorkflowAgent</h3><pre>import {<br>  WorkflowAgent,<br>  type ModelCallStreamPart,<br>} from &#39;@ai-sdk/workflow&#39;;<br>import {<br>  convertToModelMessages,<br>  tool,<br>  type UIMessage,<br>} from &#39;ai&#39;;<br>import { getWritable } from &#39;workflow&#39;;<br>import { z } from &#39;zod&#39;;<br><br>async function searchDocumentsStep(input: {<br>  query: string;<br>}) {<br>  &#39;use step&#39;;<br>  const response = await fetch(<br>    `https://api.example.com/search?q=${encodeURIComponent(<br>      input.query,<br>    )}`,<br>  );<br><br>  if (!response.ok) {<br>    throw new Error(&#39;Document search failed&#39;);<br>  }<br><br>  return response.json();<br>}<br><br>async function publishReportStep(input: {<br>  reportId: string;<br>}) {<br>  &#39;use step&#39;;<br>  const response = await fetch(<br>    &#39;https://api.example.com/reports/publish&#39;,<br>    {<br>      method: &#39;POST&#39;,<br>      headers: {<br>        &#39;Content-Type&#39;: &#39;application/json&#39;,<br>      },<br>      body: JSON.stringify(input),<br>    },<br>  );<br><br>  if (!response.ok) {<br>    throw new Error(&#39;Report publishing failed&#39;);<br>  }<br><br>  return response.json();<br>}<br><br>export async function runAssistant(<br>  messages: UIMessage[],<br>) {<br>  &#39;use workflow&#39;;<br><br>  const agent = new WorkflowAgent({<br>    model: &#39;anthropic/claude-sonnet-4-6&#39;,<br>    instructions: `<br>      You are an enterprise research assistant.<br>      Search internal documentation when needed.<br>      Prepare reports based on verified information.<br>      Never publish a report without approval.<br>    `,<br>    tools: {<br>      searchDocuments: tool({<br>        description:<br>          &#39;Search internal documentation.&#39;,<br>        inputSchema: z.object({<br>          query: z.string(),<br>        }),<br>        execute: searchDocumentsStep,<br>      }),<br>      publishReport: tool({<br>        description:<br>          &#39;Publish a prepared report.&#39;,<br>        inputSchema: z.object({<br>          reportId: z.string(),<br>        }),<br>        needsApproval: true,<br>        execute: publishReportStep,<br>      }),<br>    },<br>  });<br><br>  const result = await agent.stream({<br>    messages: convertToModelMessages(messages),<br>    writable: getWritable&lt;ModelCallStreamPart&gt;(),<br>  });<br><br>  return {<br>    messages: result.messages,<br>  };<br>}</pre><p>This example demonstrates the core pattern:</p><ul><li>The workflow function is marked with &#39;use workflow&#39;.</li><li>Tool implementations that need durable retries are marked with &#39;use step&#39;.</li><li>The report publishing tool requires human approval.</li><li>The workflow can suspend and resume while waiting for the approval decision.</li></ul><p>For a real deployment, the publishing operation should also implement server-controlled idempotency and authorization. The simple example focuses on the workflow integration rather than the complete business security layer.</p><h3>Step 4: Understand the difference between WorkflowAgent and ToolLoopAgent</h3><p>FeatureToolLoopAgentWorkflowAgentExecutionIn-memoryDurable workflowProcess restart recoveryNo automatic recoverySupported by runtimeTool retriesApplication-managedPer-step retry supportLong approval waitsRequires custom handlingDurable suspensionTool execution visibilityAI SDK telemetryWorkflow step visibilityStreamingDirect stream consumptionWorkflow writable</p><p>The recommended approach is not to replace every ToolLoopAgent with WorkflowAgent.</p><p>For short, synchronous agent interactions, an in-memory agent is often simpler.</p><p>Use durable execution when the workflow needs to outlive its original request, survive process interruptions, wait for external events, or retry individual operations independently.</p><h3>Step 5: Make Chat Streaming Resumable</h3><p>One challenge in long-running AI applications is that the backend workflow and the frontend connection have different lifecycles.</p><p>A user might:</p><ul><li>Refresh the browser.</li><li>Lose network connectivity.</li><li>Navigate away from a page.</li><li>Reconnect after a function timeout.</li></ul><p>A durable workflow may still be running even though the original HTTP connection has disappeared.</p><p>WorkflowChatTransport provides a mechanism for resuming the chat stream using the workflow run ID and previously received stream chunks.</p><p>A simplified client configuration:</p><pre>&#39;use client&#39;;<br><br>import { useChat } from &#39;@ai-sdk/react&#39;;<br>import { WorkflowChatTransport } from &#39;@ai-sdk/workflow&#39;;<br>import { useMemo } from &#39;react&#39;;<br><br>export default function Chat() {<br>  const transport = useMemo(<br>    () =&gt;<br>      new WorkflowChatTransport({<br>        api: &#39;/api/chat&#39;,<br>        maxConsecutiveErrors: 5,<br>        initialStartIndex: -50,<br>      }),<br>    [],<br>  );<br><br>  const { messages, sendMessage } = useChat({<br>    transport,<br>  });<br>  // Render conversation and handle user input.<br>}</pre><p>The server also needs the corresponding workflow stream endpoints.</p><p>This is an important architectural detail: a durable backend does not automatically make the browser connection durable. The frontend needs a way to reconnect and retrieve the appropriate part of the workflow stream.</p><p><strong>References:</strong> <a href="https://vercel.com/kb/guide/what-is-workflowagent">Vercel — WorkflowAgent</a> · <a href="https://vercel.com/kb/guide/durableagent-to-workflowagent">AI SDK — WorkflowAgent Migration</a></p><h3>7. LangGraph: Checkpointing and Interrupts</h3><p>LangGraph approaches durable agent execution through graph state and persistence.</p><p>Its persistence architecture provides two complementary mechanisms:</p><p><strong>Checkpointers</strong></p><p>Persist graph state for a specific thread.</p><p>They support:</p><ul><li>Conversation continuity</li><li>Recovery from interruptions</li><li>Human-in-the-loop workflows</li><li>Time travel</li><li>Fault tolerance</li></ul><p><strong>Stores</strong></p><p>Persist application-defined information across different threads.</p><p>They are useful for:</p><ul><li>User preferences</li><li>Long-term memory</li><li>Shared knowledge</li><li>Cross-conversation information</li></ul><p>A simplified configuration:</p><pre>import {<br>  MemorySaver,<br>  StateGraph,<br>  Annotation,<br>} from &#39;@langchain/langgraph&#39;;<br><br>const State = Annotation.Root({<br>  messages: Annotation&lt;string[]&gt;({<br>    reducer: (current, update) =&gt; [<br>      ...current,<br>      ...update,<br>    ],<br>    default: () =&gt; [],<br>  }),<br>});<br><br>const builder = new StateGraph(State);<br>// Add nodes and edges...<br>const checkpointer = new MemorySaver();<br><br>const graph = builder.compile({<br>  checkpointer,<br>});<br><br>const result = await graph.invoke(<br>  {<br>    messages: [&#39;Analyze the requested document.&#39;],<br>  },<br>  {<br>    configurable: {<br>      thread_id: &#39;analysis-123&#39;,<br>    },<br>  },<br>);</pre><p>This example uses an in-memory checkpointer for illustration.</p><p>It is not suitable for surviving process restarts. For production, use a persistent implementation such as PostgreSQL or another supported durable checkpointer.</p><h3>Human-in-the-loop with interrupts</h3><p>LangGraph provides the interrupt() function to pause a graph and wait for external input.</p><p>Conceptually:</p><pre>import {<br>  interrupt,<br>  Command,<br>} from &#39;@langchain/langgraph&#39;;<br><br>async function approvalNode(state: {<br>  proposedAction: string;<br>}) {<br>  const decision = interrupt({<br>    action: state.proposedAction,<br>    message: &#39;Approval is required.&#39;,<br>  });<br><br>  if (!decision.approved) {<br>    return {<br>      status: &#39;rejected&#39;,<br>    };<br>  }<br><br>  return {<br>    status: &#39;approved&#39;,<br>  };<br>}</pre><p>The graph persists its state through its checkpointer.</p><p>Later, the application can resume the same thread using Command with the user&#39;s response.</p><p>This is useful for workflows where execution must pause for approval or additional input.</p><p>One subtle but important implementation detail: when resuming an interrupted graph, the interrupted node may execute again from its beginning. Operations performed before interrupt() therefore need to be designed carefully to avoid duplicate side effects.</p><p><strong>References:</strong> <a href="https://docs.langchain.com/oss/javascript/langgraph/persistence">LangGraph Persistence</a> · <a href="https://docs.langchain.com/oss/javascript/langgraph/interrupts">LangGraph Interrupts</a></p><h3>8. Temporal: Durable Execution as an Infrastructure Primitive</h3><p>Temporal takes a different approach.</p><p>Rather than focusing specifically on agent state or conversation management, Temporal provides a general-purpose durable execution platform.</p><p>It is designed for long-running, stateful workflows that need to survive failures.</p><p>Two concepts are particularly important:</p><p><strong>Workflow</strong></p><p>Defines the orchestration logic and execution history.</p><p><strong>Activity</strong></p><p>Performs external or non-deterministic operations such as API requests, database access, LLM calls, and file operations.</p><p>A simplified AI workflow could look like this:</p><pre>Temporal Workflow<br>       |<br>       v<br>Retrieve Context Activity<br>       |<br>       v<br>LLM Analysis Activity<br>       |<br>       v<br>Validation Activity<br>       |<br>       v<br>Wait for Approval Signal<br>       |<br>       v<br>Execute Business Operation<br>       |<br>       v<br>Complete</pre><p>Temporal records workflow history and uses deterministic replay to reconstruct execution state.</p><p>External interactions belong in Activities so their results can be recorded and reused during replay.</p><p>This separation is fundamental.</p><p>An LLM invocation should not be performed directly in a deterministic workflow definition. It should be executed as an Activity.</p><p>The same applies to database calls and external HTTP requests.</p><h3>When does Temporal make sense?</h3><p>Temporal is particularly relevant when:</p><ul><li>Workflows span multiple services.</li><li>Tasks may run for hours or days.</li><li>Strong recovery requirements exist.</li><li>Human approval is part of the process.</li><li>Many independent workers execute tasks.</li><li>Workflow execution is critical to business operations.</li></ul><p>It also introduces additional infrastructure and operational responsibilities.</p><p>Teams need to understand workflow history, Activity retries, Worker deployment, workflow versioning, and task queue management.</p><p><strong>References:</strong> <a href="https://docs.temporal.io/ai">Temporal — Durable AI</a> · <a href="https://docs.temporal.io/develop/typescript">Temporal TypeScript SDK</a> · <a href="https://docs.temporal.io/workflow-definition">Temporal Workflow Determinism</a></p><h3>9. Inngest and DBOS: Other Approaches Worth Evaluating</h3><p>Temporal and LangGraph are not the only options.</p><p>For TypeScript-based applications, two other projects are worth considering.</p><h3>Inngest</h3><p>Inngest provides durable functions and agent workflows.</p><p>Its execution model is built around steps.</p><p>For example:</p><pre>import { inngest } from &#39;./client&#39;;<br><br>export const researchWorkflow = inngest.createFunction(<br>  {<br>    id: &#39;research-workflow&#39;,<br>  },<br>  async ({ event, step }) =&gt; {<br>    const context = await step.run(<br>      &#39;retrieve-context&#39;,<br>      async () =&gt; {<br>        return retrieveContext(event.data.query);<br>      },<br>    );<br><br>    const analysis = await step.run(<br>      &#39;generate-analysis&#39;,<br>      async () =&gt; {<br>        return generateAnalysis(context);<br>      },<br>    );<br><br>    const report = await step.run(<br>      &#39;validate-report&#39;,<br>      async () =&gt; {<br>        return validateReport(analysis);<br>      },<br>    );<br><br>    return report;<br>  },<br>);</pre><p>The runtime checkpoints completed steps and resumes from the failed step when execution recovers.</p><p>Inngest also provides step.waitForEvent() for human approvals and other external events.</p><p>Its durable agent approach goes further by treating each model call and tool call as a separately checkpointed step.</p><p>This is useful when we want to write ordinary TypeScript functions instead of defining an explicit graph.</p><h3>DBOS</h3><p>DBOS provides durable execution for TypeScript and other languages, using a database-backed workflow model.</p><p>Its TypeScript SDK offers:</p><ul><li>Workflow registration</li><li>Durable steps</li><li>Configurable retries</li><li>Workflow IDs</li><li>Idempotency and deduplication mechanisms</li><li>Background execution</li><li>Recovery after process restarts</li></ul><p>A simplified example:</p><pre>import { DBOS } from &#39;@dbos-inc/dbos-sdk&#39;;<br><br>async function retrieveDocuments() {<br>  // External API or database operation<br>  return [&#39;document-1&#39;, &#39;document-2&#39;];<br>}<br><br>async function analyzeDocuments() {<br>  // LLM interaction<br>  return {<br>    summary: &#39;Analysis completed&#39;,<br>  };<br>}<br><br>async function researchWorkflow() {<br>  const documents = await DBOS.runStep(<br>    () =&gt; retrieveDocuments(),<br>    {<br>      name: &#39;retrieve-documents&#39;,<br>      retriesAllowed: true,<br>      maxAttempts: 3,<br>    },<br>  );<br><br>  const analysis = await DBOS.runStep(<br>    () =&gt; analyzeDocuments(),<br>    {<br>      name: &#39;analyze-documents&#39;,<br>      retriesAllowed: true,<br>      maxAttempts: 3,<br>    },<br>  );<br><br>  return {<br>    documents,<br>    analysis,<br>  };<br>}<br><br>const workflow = DBOS.registerWorkflow(<br>  researchWorkflow,<br>);</pre><p>This example shows the basic step model. A production application must initialize DBOS, register the workflow, and configure its database and worker lifecycle.</p><p>DBOS also requires deterministic workflow code and appropriate serialization of workflow inputs and outputs.</p><h3>How do these approaches compare?</h3><p>TechnologyMain strengthBest suited forLangGraphGraph state and checkpointingStateful agent workflowsTemporalGeneral-purpose durable executionComplex distributed business processesInngestDurable TypeScript functions and agentsEvent-driven agent workflowsDBOSDatabase-backed durable executionTypeScript applications needing durable stepsVercel Workflow + WorkflowAgentNative AI SDK integrationDurable agents in Vercel-based applications</p><p>There is no universally correct choice.</p><p>The right decision depends on the execution model, deployment environment, workflow duration, infrastructure constraints, and the level of operational control required.</p><p><strong>References:</strong> <a href="https://www.inngest.com/docs/durable-execution/durable-agents">Inngest Durable Agents</a> · <a href="https://docs.dbos.dev/typescript/tutorials/workflow-tutorial">DBOS TypeScript Workflows</a></p><h3>10. Designing a Production-Ready Agent Workflow</h3><p>Choosing a durable execution framework is only one part of the solution.</p><p>We also need to design the workflow itself.</p><p>Let’s consider a document analysis and approval workflow.</p><p>The system should:</p><ol><li>Receive a document.</li><li>Extract its content.</li><li>Analyze the information.</li><li>Generate a proposed report.</li><li>Wait for human approval.</li><li>Publish the approved report.</li><li>Store the final result.</li></ol><p>A possible architecture:</p><pre>             User<br>              |<br>              v<br>        AI Application<br>              |<br>              v<br>       Workflow Runtime<br>              |<br>      +-------+--------+<br>      |                |<br>      v                v<br>  Extract Step    Analysis Step<br>      |                |<br>      v                v<br>   Storage          LLM / Tools<br>      |                |<br>      +--------+-------+<br>               |<br>               v<br>        Validation Step<br>               |<br>               v<br>        Approval Required<br>               |<br>        +------+------+<br>        |             |<br>      Reject        Approve<br>        |             |<br>        v             v<br>      Stop      Publish Step<br>                      |<br>                      v<br>                 Save Result</pre><p>Several design decisions matter here.</p><h3>Persist the right information</h3><p>Do not put every document, model response, or binary object into the workflow’s event history.</p><p>Use references to external storage for large payloads.</p><p>Persist only the data required to recover the next step.</p><h3>Separate planning from execution</h3><p>The LLM may decide which operation should happen next, but the runtime should control which operations are actually executed.</p><p>The agent should not own unrestricted workflow persistence or authorization decisions.</p><h3>Define explicit failure boundaries</h3><p>A document extraction failure should not necessarily require the analysis to restart.</p><p>An approval timeout should not cause the document to be processed again.</p><p>A publishing failure should not require repeating the original LLM request.</p><p>Each operation should have a clear recovery strategy.</p><h3>Keep business state authoritative</h3><p>A workflow checkpoint records execution progress.</p><p>It should not replace the authoritative transaction state of the business system.</p><p>For example, an approval workflow may persist its state in the orchestration engine, but the actual transaction should be committed and recorded in the relevant business system.</p><h3>Make workflows version-aware</h3><p>Long-running workflows can remain active across application deployments.</p><p>A workflow that started using one version of the code may resume after a newer version has been deployed.</p><p>Changing step ordering, workflow logic, or serialized state formats can break recovery if compatibility is not considered.</p><p>Workflow versioning, migration strategies, and backward-compatible payloads should be part of the deployment design.</p><h3>11. Common Mistakes in Durable Agent Implementations</h3><p>There are a few mistakes I would specifically avoid.</p><p><strong>1. Treating conversation history as workflow state</strong></p><p>Saving messages does not mean the agent can resume from the exact point where it stopped.</p><p><strong>2. Retrying everything</strong></p><p>A failed tool should not force all previous model calls and tool executions to run again.</p><p>Retry boundaries should be explicit.</p><p><strong>3. Assuming exactly-once side effects</strong></p><p>A durable workflow cannot automatically guarantee exactly-once behavior across arbitrary external services.</p><p>Use idempotency keys, deduplication, and reconciliation.</p><p><strong>4. Storing large payloads inside checkpoints</strong></p><p>Store references to documents and large artifacts instead of repeatedly serializing them.</p><p><strong>5. Ignoring workflow versioning</strong></p><p>A long-running workflow may outlive multiple application deployments.</p><p>Plan how in-flight executions will behave when code changes.</p><p><strong>6. Keeping workers alive while waiting for approval</strong></p><p>A durable wait should suspend execution and release the worker rather than holding a process open.</p><p><strong>7. Using durable execution for every interaction</strong></p><p>A simple question-answering endpoint does not necessarily need a workflow engine.</p><p>The added infrastructure should solve an actual reliability problem.</p><p><strong>8. Confusing agent reasoning with business authorization</strong></p><p>An agent deciding to perform an action does not mean that action is authorized.</p><p>Sensitive operations must be validated and approved by trusted application logic.</p><h3>12. What Can an AI Chapter Lead Contribute?</h3><p>I believe durable execution is particularly important when multiple teams are building AI-powered features within the same organization.</p><p>Without shared patterns, each team may implement its own persistence, retry logic, background execution, and approval mechanisms.</p><p>This creates duplicated engineering effort and inconsistent reliability.</p><p>There are several practical areas where an AI Chapter Lead can contribute.</p><h3>1. Establish a Common Durable Execution Pattern</h3><p>Define when an agent should run in memory and when it should run inside a durable workflow.</p><p>For example:</p><p>Use caseRecommended approachSimple chat responseIn-memory agentShort tool interactionStandard agent with controlled retriesMulti-step background taskDurable workflowHuman approvalDurable wait and resumeLong-running researchDurable agentCross-service business processWorkflow engineIndependent subagent tasksDurable child workflows or delegated executions</p><p>This prevents unnecessary infrastructure while ensuring that critical workflows have appropriate recovery mechanisms.</p><h3>2. Build a Shared Workflow Abstraction</h3><p>We can create a reusable internal package that provides:</p><ul><li>Workflow lifecycle management</li><li>Checkpoint persistence</li><li>Standard retry policies</li><li>Idempotency helpers</li><li>Cancellation support</li><li>Human approval mechanisms</li><li>Execution state management</li><li>Observability integration</li></ul><p>Teams should not need to implement these capabilities independently.</p><p>However, shared abstractions should not hide framework-specific behavior that affects reliability.</p><h3>3. Define Standard Retry and Recovery Policies</h3><p>Not every failure should trigger another attempt.</p><p>For example:</p><p>FailureStrategyTemporary network failureRetry with backoffProvider rate limitRespect retry guidance and budgetInvalid model outputValidate and optionally regenerateAuthorization failureStop and escalateInvalid tool argumentsCorrect or request clarificationBusiness rule violationStop or request human reviewExternal operation timeoutReconcile before retrying</p><p>We can define common retry budgets, deadlines, maximum attempts, and error classification rules.</p><p>This reduces inconsistent failure handling across applications.</p><h3>4. Establish Idempotency Standards</h3><p>Every operation that creates an external side effect should have an explicit idempotency strategy.</p><p>For example:</p><ul><li>Database writes</li><li>Report publishing</li><li>Email sending</li><li>External API requests</li><li>Business transaction execution</li><li>Agent-to-agent task submission</li></ul><p>This is especially important when workflows involve retries or uncertain execution outcomes.</p><h3>5. Standardize Human Approval</h3><p>We can establish a shared approval model that defines:</p><ul><li>Who can approve an action.</li><li>Which operations require approval.</li><li>How approval requests are stored.</li><li>How decisions are associated with workflow executions.</li><li>What happens when approval expires.</li><li>How rejected actions are handled.</li><li>How approval decisions are audited.</li></ul><p>This makes human-in-the-loop workflows consistent across teams.</p><h3>6. Build a Reference Implementation</h3><p>A practical internal example should demonstrate:</p><ol><li>Durable agent execution.</li><li>Step-level checkpointing.</li><li>Independent retries.</li><li>Human approval.</li><li>Resume after application restart.</li><li>Idempotent external operations.</li><li>Distributed tracing.</li><li>Workflow versioning.</li></ol><p>This is more useful than a standalone demo that only shows a successful agent response.</p><h3>7. Measure Reliability</h3><p>We should define measurable outcomes.</p><p>Useful metrics include:</p><ul><li>Workflow completion rate</li><li>Recovery success rate</li><li>Number of repeated model calls</li><li>Duplicate side-effect attempts</li><li>Approval wait duration</li><li>Retry count per workflow</li><li>Average workflow duration</li><li>Cost per successful workflow</li><li>Human escalation rate</li></ul><p>The objective should be to understand whether durable execution actually improves reliability without creating unnecessary complexity.</p><h3>13. When Should We Adopt Durable Execution?</h3><p>A durable execution framework is particularly valuable when:</p><ul><li>A task involves multiple dependent operations.</li><li>Execution can exceed HTTP request timeouts.</li><li>A workflow must survive process restarts.</li><li>Expensive model calls should not be repeated unnecessarily.</li><li>A human may need to approve an operation later.</li><li>The agent interacts with external systems that may fail independently.</li><li>Multiple workers or services participate in one business process.</li></ul><p>For a short chat response, a regular AI SDK agent may be sufficient.</p><p>For a multi-hour research workflow or a business operation that cannot be safely restarted from the beginning, durable execution becomes much more important.</p><p>The correct architecture is the simplest one that provides the required failure guarantees.</p><h3>Conclusion</h3><p>AI agents are becoming more capable, but greater capability introduces additional operational complexity.</p><p>An agent that can call tools, delegate tasks, and interact with external systems must also be able to recover from failures, preserve execution progress, and handle interruptions safely.</p><p>Durable execution provides the foundation for this.</p><p>Checkpointing preserves progress. Retries recover from transient failures. Idempotency prevents duplicate side effects. Durable waits enable human approval without keeping workers occupied. Workflow versioning allows long-running executions to survive application changes.</p><p>For AI engineering teams, the goal should not be to make every agent use a workflow engine.</p><p>It should be to identify which operations require durability and apply the right execution model to them.</p><p><strong>My recommendation:</strong> Start with one multi-step workflow that includes an external API call and a human approval step. Implement it with a durable execution framework, simulate process failures, and verify that completed work is not repeated and external side effects are not duplicated.</p><p>A production-ready agent is not one that only works when everything goes right.</p><p><strong>It is one that knows how to recover when things go wrong.</strong></p><h3>References and Further Reading</h3><h3>Vercel AI SDK and Workflow</h3><ol><li><a href="https://vercel.com/kb/guide/what-is-workflowagent">Vercel — What is WorkflowAgent?</a> — Durable AI agents, retries, approvals, and resumable execution.</li><li><a href="https://vercel.com/kb/guide/ai-gateway-and-ai-sdk">Vercel — Build AI Agents with AI SDK and AI Gateway</a> — AI SDK and Workflow integration.</li><li><a href="https://vercel.com/kb/workflow-sdk">Vercel — Workflow SDK</a> — Durable functions, steps, and execution model.</li><li><a href="https://vercel.com/kb/guide/durableagent-to-workflowagent">Vercel — Migrate from DurableAgent to WorkflowAgent</a> — AI SDK v7 integration changes.</li></ol><h3>LangGraph</h3><ol><li><a href="https://docs.langchain.com/oss/javascript/langgraph/persistence">LangGraph — Persistence</a> — Checkpointers, stores, state, and memory.</li><li><a href="https://docs.langchain.com/oss/javascript/langgraph/interrupts">LangGraph — Interrupts</a> — Human-in-the-loop and resumable execution.</li><li><a href="https://docs.langchain.com/oss/javascript/langgraph/fault-tolerance">LangGraph — Fault Tolerance</a> — Recovery and execution reliability.</li></ol><h3>Temporal</h3><ol><li><a href="https://docs.temporal.io/ai">Temporal — Durable AI</a> — Durable execution for AI applications.</li><li><a href="https://docs.temporal.io/develop/typescript">Temporal — TypeScript SDK</a> — Workflows, Activities, Workers, and message passing.</li><li><a href="https://docs.temporal.io/workflow-definition">Temporal — Workflow Determinism</a> — Replay-safe workflow implementation.</li><li><a href="https://docs.temporal.io/ai/cookbook">Temporal — AI Cookbook</a> — AI integrations, agent loops, tool calling, and human approval.</li></ol><h3>Inngest</h3><ol><li><a href="https://www.inngest.com/docs/durable-execution">Inngest — Durable Execution</a> — Steps, retries, checkpoints, and event handling.</li><li><a href="https://www.inngest.com/docs/durable-execution/durable-agents">Inngest — Durable Agents</a> — Agent loops, independent retries, and subagent delegation.</li><li><a href="https://www.inngest.com/docs/durable-execution/durable-agents/human-in-the-loop">Inngest — Human-in-the-Loop</a> — Durable approval and resume patterns.</li></ol><h3>DBOS</h3><ol><li><a href="https://docs.dbos.dev/typescript/tutorials/workflow-tutorial">DBOS — TypeScript Workflows</a> — Durable workflows and automatic recovery.</li><li><a href="https://docs.dbos.dev/typescript/reference/workflows-steps">DBOS — Workflows and Steps</a> — Retry configuration and step execution.</li><li><a href="https://docs.dbos.dev/architecture">DBOS — Architecture</a> — Checkpointing, recovery, and deterministic execution.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bb87a8da3a5b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Queueing Theory in Go: Understanding Latency, Throughput, and Saturation]]></title>
            <link>https://medium.com/@serifcolakel/queueing-theory-in-go-understanding-latency-throughput-and-saturation-8a2ad467909d?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/8a2ad467909d</guid>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[productivity]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 18:24:40 GMT</pubDate>
            <atom:updated>2026-11-03T18:24:40.418Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JpMNlhBItmWTv60r4uxCWA.png" /><figcaption>Queueing Theory in Go: Understanding Latency, Throughput, and Saturation Image</figcaption></figure><p>Your service handles 500 requests per second.</p><p>The CPU is not fully utilized. The database is responding normally. The average processing time looks reasonable.</p><p>Yet your p99 latency keeps increasing.</p><p>What is happening?</p><p>In many cases, the problem isn’t how long requests take to execute. It’s how long they spend waiting before execution even begins.</p><p>This is where <strong>queueing theory</strong> becomes useful.</p><p>Queueing theory gives us a way to reason about arrival rates, service capacity, concurrency, waiting time, and system saturation. It helps explain why a service can look healthy under moderate traffic and suddenly experience a dramatic increase in latency as it approaches its capacity.</p><p>In this article, we’ll explore the fundamentals of queueing theory and apply them to practical Go backend systems.</p><p>We’ll cover:</p><ul><li>Arrival rate, service rate, and utilization</li><li>Little’s Law and its practical applications</li><li>Why latency increases near saturation</li><li>The relationship between concurrency and throughput</li><li>Building bounded queues and worker pools in Go</li><li>Queue sizing, admission control, and overload behavior</li><li>Metrics and load-testing strategies</li></ul><p>The goal isn’t to turn every backend engineer into a mathematician. It’s to make better engineering decisions using a few surprisingly powerful relationships.</p><h3>1. Every Backend Service Is a Queueing System</h3><p>Consider a typical HTTP service:</p><pre>Client Requests<br>      |<br>      v<br>   HTTP Server<br>      |<br>      v<br>  Request Queue<br>      |<br>      v<br>  Worker Pool<br>      |<br>      v<br> Database / API<br>      |<br>      v<br>   Response</pre><p>Every request follows a similar lifecycle:</p><ol><li>It arrives at the system.</li><li>It waits for an available resource.</li><li>It gets processed.</li><li>It leaves the system.</li></ol><p>The important distinction is that <strong>processing time and waiting time are different things</strong>.</p><p>Imagine two requests:</p><p>RequestWaiting timeProcessing timeTotalA5 ms45 ms50 msB250 ms45 ms295 ms</p><p>Both requests require exactly the same processing time.</p><p>But Request B takes almost six times longer because it spends most of its lifetime waiting.</p><p>This is why optimizing database queries or application code alone doesn’t always improve end-to-end latency.</p><p>Sometimes the queue is the bottleneck.</p><h3>2. The Three Variables That Explain Queueing Behavior</h3><p>Most basic queueing models start with three variables.</p><h3>Arrival Rate (λ)</h3><p>The arrival rate represents how frequently new requests enter the system.</p><p>For example:</p><p><strong>λ = 500 requests/second</strong></p><p>It describes incoming demand, not necessarily completed work.</p><h3>Service Rate (μ)</h3><p>The service rate describes how quickly a worker can process requests.</p><p>If a worker takes an average of 20 milliseconds per request:</p><p><strong>μ = 1 / 0.020 = 50 requests/second</strong></p><p>One worker can theoretically process 50 requests per second under that average service time.</p><p>With 10 equivalent workers, the theoretical aggregate service capacity becomes:</p><p><strong>c × μ = 10 × 50 = 500 requests/second</strong></p><p>Here, c represents the number of workers.</p><p>This assumes the workers can operate independently and that another shared resource doesn’t become the bottleneck.</p><h3>Utilization (ρ)</h3><p>Utilization describes how much of the available service capacity is being consumed.</p><p>For a simple system with c equivalent workers:</p><p><strong>ρ = λ / (c × μ)</strong></p><p>Suppose:</p><ul><li>Arrival rate: 400 requests/second</li><li>Service rate per worker: 50 requests/second</li><li>Number of workers: 10</li></ul><p>Then:</p><p><strong>ρ = 400 / (10 × 50) = 0.8 = 80%</strong></p><p>The system is operating at 80% utilization.</p><p>That sounds reasonable.</p><p>But here’s the important part: <strong>80% utilization does not mean 20% of the system is freely available at all times.</strong></p><p>Traffic is variable. Requests don’t arrive at perfectly regular intervals, and processing times aren’t identical.</p><p>A system can experience queueing delays even when its average utilization is below 100%.</p><p>And as utilization approaches capacity, those delays can grow rapidly.</p><h3>3. Little’s Law: The Most Useful Queueing Relationship</h3><p>One of the most practical results in queueing theory is Little’s Law:</p><p><strong>L = λ × W</strong></p><p>Where:</p><ul><li>L = average number of requests in the entire system</li><li>λ = average arrival rate</li><li>W = average time spent in the entire system</li></ul><p>In backend engineering, this gives us a direct relationship between throughput, latency, and concurrency.</p><p>For example:</p><pre>Throughput: 200 requests/second<br>Average latency: 150 ms</pre><p>Convert the latency to seconds:</p><p><strong>W = 0.150 seconds</strong></p><p>Then:</p><p><strong>L = 200 × 0.150 = 30</strong></p><p>On average, there are approximately 30 requests in the system.</p><p>This is incredibly useful because it allows us to estimate concurrency from production measurements.</p><p>Let’s look at another example.</p><p>A service processes 1,000 requests per second, with an average time in the system of 80 milliseconds.</p><p><strong>L = 1,000 × 0.080 = 80</strong></p><p>The service has approximately 80 requests in flight on average.</p><p>This doesn’t mean we should automatically configure 80 workers.</p><p>It means the observed workload has an average concurrency of approximately 80. Worker count, resource limits, queue capacity, and service time still need to be considered separately.</p><h3>Queue length versus requests in the system</h3><p>There’s an important distinction here.</p><p>Little’s Law can describe either the entire system or just the waiting queue, provided the measurements use matching definitions.</p><p>For the entire system:</p><p><strong>L = λ × W</strong></p><p>For the waiting queue:</p><p><strong>Lq = λ × Wq</strong></p><p>Where:</p><ul><li>Lq = average number of requests waiting in the queue</li><li>λ = average arrival rate into the queue</li><li>Wq = average time spent waiting in the queue</li></ul><p>The distinction matters when instrumenting a Go worker pool.</p><p>If we measure only queue depth, we shouldn’t compare it directly with total request latency. We need to compare it with queue waiting time.</p><h3>When should you use Little’s Law?</h3><p>It’s useful for:</p><ul><li>Estimating average in-flight requests</li><li>Understanding queue growth</li><li>Planning concurrency limits</li><li>Checking whether observed metrics are consistent</li><li>Estimating the waiting time implied by a queue</li><li>Identifying where work accumulates</li></ul><p>However, Little’s Law describes long-run averages in a stable system. It doesn’t predict p99 latency or tell us how a system behaves during a sudden traffic spike.</p><p>For that, we need to understand utilization and variability.</p><h3>4. Why Latency Explodes Near Saturation</h3><p>Let’s consider a simplified single-server queue.</p><p>Assume:</p><ul><li>Requests arrive according to a Poisson process.</li><li>Service times are exponentially distributed.</li><li>There is one server.</li><li>The queue has unlimited capacity.</li></ul><p>This is commonly called an <strong>M/M/1 queue</strong>.</p><p>For this particular model, the expected time in the system is:</p><p><strong>W = 1 / (μ — λ)</strong></p><p>And the expected waiting time in the queue is:</p><p><strong>Wq = ρ / (μ — λ)</strong></p><p>These formulas are model-specific. Real production systems often have multiple workers, correlated traffic, variable service times, and bounded queues.</p><p>But the model illustrates an important behavior.</p><p>Assume the server can process 100 requests per second.</p><p>Arrival rateUtilizationAverage time in system50 req/s50%20 ms70 req/s70%33.3 ms80 req/s80%50 ms90 req/s90%100 ms95 req/s95%200 ms99 req/s99%1,000 ms</p><p><em>Illustrative M/M/1 model, with a service rate of 100 requests per second.</em></p><p>The service rate never changes.</p><p>Yet the average response time increases from 20 milliseconds to one full second as utilization approaches 100%.</p><p>This is the queueing effect that makes operating a service continuously at maximum utilization dangerous.</p><p>The system doesn’t necessarily become slower at processing individual requests. It becomes slower at getting requests to the point where processing can begin.</p><p>This also explains why a small traffic increase can cause a disproportionately large latency increase.</p><p>In real systems, variability can make the situation worse. Bursty arrivals, slow dependencies, garbage collection pauses, lock contention, and uneven request costs all contribute to queueing.</p><p><strong>High utilization leaves very little room to absorb variability.</strong></p><h3>5. Concurrency Is Not the Same as Throughput</h3><p>A common approach to improving performance is increasing concurrency.</p><p>If 10 workers aren’t enough, why not use 100?</p><p>Because more concurrency doesn’t necessarily produce more throughput.</p><p>Imagine a service that queries a database.</p><pre>Go Service<br>    |<br>    v<br>100 Concurrent Requests<br>    |<br>    v<br>Database Connection Pool<br>    |<br>    v<br>20 Available Connections</pre><p>Only 20 requests can use a database connection simultaneously.</p><p>The remaining requests may wait for a connection.</p><p>Increasing application concurrency from 100 to 500 doesn’t increase database capacity.</p><p>It may simply move the queue from the application into the database connection pool.</p><p>This is an important principle:</p><p><strong>Increasing concurrency can move the bottleneck without removing it.</strong></p><p>A useful way to reason about concurrency is Little’s Law:</p><p><strong>L = λ × W</strong></p><p>If throughput remains constant while the number of concurrent requests increases, the average time spent in the system must also increase.</p><p>For example:</p><pre>Throughput: 100 requests/second<br>Concurrency: 100<br>Average time: 1 second</pre><p>Now suppose concurrency increases to 300, but throughput remains 100 requests per second.</p><pre>Concurrency: 300<br>Average time: 3 seconds</pre><p>The system is carrying more work, but it isn’t completing more work.</p><p>This is why measuring concurrency without measuring throughput and latency can be misleading.</p><h3>6. Implementing a Bounded Worker Pool in Go</h3><p>Let’s move from theory to implementation.</p><p>A worker pool is a common way to control how much work executes concurrently.</p><p>But a worker pool alone isn’t enough.</p><p>If the incoming queue is unbounded, the service can continue accepting work long after its processing capacity is exhausted.</p><p>A bounded queue provides an explicit limit.</p><p>Here’s a simplified implementation using Go channels:</p><pre>package main<br><br>import (<br>    &quot;context&quot;<br>    &quot;errors&quot;<br>    &quot;sync&quot;<br>)<br><br>var (<br>    ErrQueueFull  = errors.New(&quot;worker queue is full&quot;)<br>    ErrPoolClosed = errors.New(&quot;worker pool is closed&quot;)<br>)<br><br>type Job func(context.Context) error<br><br>type Pool struct {<br>    jobs chan Job<br>    ctx    context.Context<br>    cancel context.CancelFunc<br>    mu     sync.Mutex<br>    closed bool<br>    wg sync.WaitGroup<br>}<br><br>func NewPool(<br>    parent context.Context,<br>    workers int,<br>    queueSize int,<br>) *Pool {<br>    if workers &lt;= 0 {<br>        panic(&quot;workers must be greater than zero&quot;)<br>    }<br>    if queueSize &lt; 0 {<br>        panic(&quot;queue size cannot be negative&quot;)<br>    }<br>    ctx, cancel := context.WithCancel(parent)<br>    p := &amp;Pool{<br>        jobs:   make(chan Job, queueSize),<br>        ctx:    ctx,<br>        cancel: cancel,<br>    }<br>    p.wg.Add(workers)<br>    for i := 0; i &lt; workers; i++ {<br>        go p.worker()<br>    }<br>    return p<br>}<br><br>func (p *Pool) Submit(job Job) error {<br>    if job == nil {<br>        return errors.New(&quot;job cannot be nil&quot;)<br>    }<br>    p.mu.Lock()<br>    defer p.mu.Unlock()<br>    if p.closed {<br>        return ErrPoolClosed<br>    }<br>    if err := p.ctx.Err(); err != nil {<br>        return err<br>    }<br>    select {<br>    case p.jobs &lt;- job:<br>        return nil<br>    default:<br>        return ErrQueueFull<br>    }<br>}<br><br>func (p *Pool) worker() {<br>    defer p.wg.Done()<br>    for {<br>        select {<br>        case &lt;-p.ctx.Done():<br>            return<br>        case job := &lt;-p.jobs:<br>            if p.ctx.Err() != nil {<br>                return<br>            }<br>            if job != nil {<br>                _ = job(p.ctx)<br>            }<br>        }<br>    }<br>}<br><br>func (p *Pool) Close() {<br>    p.mu.Lock()<br>    if p.closed {<br>        p.mu.Unlock()<br>        return<br>    }<br>    p.closed = true<br>    p.cancel()<br>    p.mu.Unlock()<br>    p.wg.Wait()<br>}</pre><p>The implementation demonstrates a few important ideas:</p><ul><li>The number of workers is fixed.</li><li>The queue has a bounded capacity.</li><li>Submit rejects work immediately when the queue is full.</li><li>Worker execution receives a cancellation context.</li><li>Submission and shutdown are coordinated using a mutex.</li><li>Close cancels workers and waits for them to exit.</li></ul><p>This is an intentionally small example, not a complete production job-processing framework.</p><p>In particular, it uses immediate cancellation rather than graceful draining. It also doesn’t provide per-job results, panic recovery, retry handling, or error reporting.</p><p>Those behaviors should be designed explicitly for the workload.</p><p>Also remember that context cancellation is cooperative. A job that ignores its context can prevent Close from returning.</p><h3>Why bounded queues matter</h3><p>Consider a service with:</p><pre>Workers: 20<br>Queue capacity: 500<br>Average processing time: 100 ms</pre><p>Each worker can theoretically process:</p><p><strong>μ = 1 / 0.1 = 10 jobs/second</strong></p><p>Aggregate service capacity:</p><p><strong>c × μ = 20 × 10 = 200 jobs/second</strong></p><p>If all workers are busy and the queue contains 500 jobs, the approximate queue-draining time is:</p><p><strong>Wq ≈ 500 / 200 = 2.5 seconds</strong></p><p>This is a rough estimate assuming the workers continue processing at the same aggregate rate and no new jobs arrive.</p><p>If your API has a 500-millisecond response deadline, accepting work into this queue may be pointless.</p><p>The request might time out before a worker even starts processing it.</p><p>A queue is not automatically useful just because it can hold more work.</p><p>It should hold only the amount of work your system can reasonably process before that work becomes irrelevant.</p><h3>7. Queue Capacity Is a Latency Decision</h3><p>Developers often select queue capacity using arbitrary values:</p><pre>make(chan Job, 1000)</pre><p>But why 1,000?</p><p>Why not 100 or 10,000?</p><p>A better approach is to start with a latency budget.</p><p>Suppose:</p><pre>Maximum acceptable queue waiting time: 500 ms<br>Aggregate processing capacity: 200 jobs/second</pre><p>A rough initial queue capacity can be estimated as:</p><p><strong>Queue Capacity ≈ Aggregate Service Capacity × Target Queue Waiting Time</strong></p><p>Given:</p><ul><li>Aggregate service capacity: 200 jobs/second</li><li>Target queue waiting time: 0.5 seconds</li></ul><p>The calculation becomes:</p><p><strong>Q ≈ 200 × 0.5 = 100 jobs</strong></p><p>This suggests that a queue capacity around 100 jobs corresponds to approximately 500 milliseconds of waiting at the assumed completion rate, if the queue is full and no new jobs arrive.</p><p>This is not a worst-case guarantee.</p><p>Actual waiting time depends on worker availability, service-time distribution, traffic bursts, and the number of jobs already executing.</p><p>Still, it gives us a much more defensible starting point than choosing a large number without understanding its consequences.</p><h3>Queue age is often more useful than queue depth</h3><p>Imagine two systems:</p><pre>System A:<br>Queue depth: 100<br>Oldest job: 10 ms<br><br>System B:<br>Queue depth: 20<br>Oldest job: 2 seconds</pre><p>System B has fewer queued jobs, but its users may be experiencing a much worse situation.</p><p>Queue depth tells us how much work is waiting.</p><p>Queue age tells us how long that work has already been waiting.</p><p>For latency-sensitive systems, both metrics are important.</p><h3>8. Admission Control: Decide Whether Work Should Enter</h3><p>A bounded queue provides a capacity limit.</p><p>Admission control adds a policy for deciding whether incoming work should be accepted.</p><p>For example:</p><pre>func HandleRequest(<br>    ctx context.Context,<br>    queue chan Job,<br>    job Job,<br>) error {<br>    if err := ctx.Err(); err != nil {<br>        return err<br>    }<br><br>    select {<br>    case queue &lt;- job:<br>        return nil<br>    case &lt;-ctx.Done():<br>        return ctx.Err()<br>    default:<br>        return ErrQueueFull<br>    }<br>}</pre><p>This is a simplified non-blocking admission decision.</p><p>When the queue is full, the request is rejected instead of waiting indefinitely.</p><p>In a real HTTP handler, this could translate into an overload response such as:</p><pre>HTTP/1.1 503 Service Unavailable<br>Retry-After: 1</pre><p>The exact status and retry policy depend on the API contract.</p><p>There are several possible admission strategies:</p><p><strong>Reject immediately</strong></p><p>Useful when waiting is unlikely to produce a result within the request deadline.</p><p><strong>Wait for a bounded period</strong></p><p>Useful when short bursts are expected and a small amount of waiting is acceptable.</p><p><strong>Prioritize work</strong></p><p>Useful when critical operations should be processed ahead of background or low-priority tasks.</p><p><strong>Apply load shedding</strong></p><p>Useful when the system is overloaded and preserving the health of the remaining workload is more important than accepting every request.</p><p>The important part is to make overload behavior intentional.</p><h3>9. The Connection to Backpressure, Load Shedding, and Adaptive Concurrency</h3><p>Queueing theory provides the foundation for several resilience patterns we’ve discussed in this series.</p><p>They solve different parts of the same problem.</p><p>PatternWhat it controlsBackpressureHow quickly work enters the systemLoad sheddingWhich work should be rejectedBulkheadsHow resources are isolatedRetry budgetsHow much additional traffic retries generateAdaptive concurrencyHow many requests execute simultaneouslyHedged requestsHow to reduce tail latency through controlled parallelism</p><p>These mechanisms become much more effective when we understand the queueing behavior behind them.</p><p>For example, an adaptive concurrency controller can reduce the number of in-flight requests when latency indicates that a dependency is approaching saturation.</p><p>A bounded queue prevents the service from accumulating unlimited waiting work.</p><p>Load shedding rejects requests that cannot be processed within a useful time window.</p><p>Together, these mechanisms help keep the system stable under changing traffic conditions.</p><h3>10. What Should You Measure in Production?</h3><p>Queueing theory is useful only when its variables can be observed.</p><p>At a minimum, I’d monitor the following metrics.</p><p>MetricWhy it mattersArrival rateIncoming demandCompletion rateActual processing capacityQueue depthAmount of waiting workQueue ageHow long work has been waitingIn-flight requestsCurrent concurrencyQueue wait durationTime spent waiting before executionService durationActual processing timeRejected requestsAdmission-control behaviorp95 / p99 latencyUser-facing latency distribution</p><p>A particularly useful metric is the difference between total request duration and processing duration.</p><pre>Total request duration: 350 ms<br>Queue wait:              250 ms<br>Processing duration:     100 ms</pre><p>If processing duration is stable but total latency increases, the queue is a strong candidate for investigation.</p><p>For Go services, you can expose these measurements through Prometheus.</p><p>Example metric names:</p><pre>http_requests_total<br>http_request_duration_seconds<br><br>worker_pool_queue_depth<br>worker_pool_queue_wait_seconds<br>worker_pool_active_workers<br>worker_pool_jobs_rejected_total<br>worker_pool_job_duration_seconds</pre><p>Use histograms for durations and counters for events. Queue depth and active workers are gauges.</p><p>Avoid putting unbounded values such as user IDs, request IDs, or raw URLs into metric labels. That creates unnecessary cardinality.</p><h3>A practical diagnostic example</h3><p>Suppose a production service starts showing the following behavior:</p><pre>Arrival rate:          800 req/s<br>Completion rate:       790 req/s<br>Active workers:        100<br>Queue depth:           Increasing<br>Queue wait p95:        450 ms<br>Processing p95:        60 ms<br>HTTP latency p95:      520 ms</pre><p>The first instinct might be to optimize the handler.</p><p>But the measurements tell a different story.</p><p>Processing time is relatively low compared with total latency.</p><p>The queue is accumulating work, and the service is completing slightly fewer requests than it receives.</p><p>Adding more workers may help only if the downstream resources have spare capacity.</p><p>Before increasing concurrency, investigate:</p><ul><li>Database connection pool saturation</li><li>Downstream API latency</li><li>CPU and memory pressure</li><li>Lock contention</li><li>Uneven job execution times</li><li>Whether requests are arriving in bursts</li></ul><p>The goal is to identify the actual bottleneck instead of simply increasing the number of workers.</p><h3>11. Testing Queueing Behavior</h3><p>A worker pool that works correctly with 10 requests may behave very differently with 10,000.</p><p>Load tests should examine more than maximum throughput.</p><p>I’d test at least four scenarios.</p><p><strong>1. Normal load</strong></p><p>Run below expected capacity and establish baseline latency, throughput, and resource usage.</p><p><strong>2. Near-saturation load</strong></p><p>Gradually increase arrival rate toward service capacity. Observe how queue wait and p99 latency change.</p><p><strong>3. Burst traffic</strong></p><p>Send a large number of requests in a short interval. Verify that the queue remains bounded and overload behavior is predictable.</p><p><strong>4. Dependency slowdown</strong></p><p>Artificially increase downstream service time. Observe whether concurrency, queue depth, and rejection rates remain controlled.</p><p>For a Go worker pool, also test:</p><ul><li>Queue-full behavior</li><li>Cancellation before execution</li><li>Shutdown while workers are active</li><li>Jobs that ignore context cancellation</li><li>Worker panics, if panic recovery is part of the design</li><li>Concurrent submission during shutdown</li></ul><p>A particularly important test is whether your service continues accepting work after its useful processing capacity has been exhausted.</p><p>A successful load test isn’t necessarily one where every request is accepted.</p><p>Sometimes the better result is a system that rejects excess work quickly while keeping accepted requests within their latency objectives.</p><h3>12. A Practical Checklist</h3><p>Before deploying a queue-based Go service, ask:</p><ul><li>Is the queue bounded?</li><li>Is the queue capacity based on a latency budget?</li><li>Do we measure queue wait time separately from processing time?</li><li>Can requests be rejected when the system is saturated?</li><li>Do jobs respect context cancellation?</li><li>Can shutdown stop workers without leaking goroutines?</li><li>Are downstream connection pools and concurrency limits understood?</li><li>Do we monitor queue depth and oldest-job age?</li><li>Have we tested burst traffic and dependency slowdown?</li><li>Are we optimizing useful throughput rather than simply maximizing accepted requests?</li></ul><p>These questions help turn queueing theory into concrete engineering decisions.</p><h3>Conclusion</h3><p>Queueing theory explains something that’s easy to overlook when building backend services:</p><p><strong>A request doesn’t need to be expensive to become slow. It only needs to wait.</strong></p><p>Little’s Law helps us reason about concurrency and latency. Utilization explains why waiting time increases rapidly near capacity. Bounded queues and admission control give us ways to prevent uncontrolled accumulation of work.</p><p>In Go, goroutines and channels make it relatively easy to build concurrent systems. But concurrency itself isn’t a capacity-planning strategy.</p><p>The important questions are:</p><ul><li>How much work enters the system?</li><li>How quickly can the system complete it?</li><li>How much work can wait?</li><li>How long can that work remain useful?</li><li>What happens when demand exceeds capacity?</li></ul><p>A reliable service doesn’t need to accept every request.</p><p>It needs to process accepted work predictably, protect its dependencies, and fail fast when it can no longer meet its latency objectives.</p><p><strong>The goal isn’t to keep every worker busy. It’s to keep the system healthy and useful under load.</strong></p><h3>References</h3><ul><li><a href="https://pubsonline.informs.org/doi/10.1287/inte.2011.0613">Little’s Law as Viewed on its 50th Anniversary — INFORMS</a></li><li><a href="https://web.mit.edu/1.041/spring2023/lectures/L8-queuing-models-2023sp.pdf">MIT — Queueing Models and Little’s Law</a></li><li><a href="https://doi.org/10.1145/3232559">Amdahl’s Law for Tail Latency — Communications of the ACM</a></li><li><a href="https://pkg.go.dev/context">Go Documentation — Context</a></li><li><a href="https://go.dev/ref/mem">Go Documentation — Go Memory Model</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8a2ad467909d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Observability for AI Agents: Tracing, Evaluation and Cost Control in Production]]></title>
            <link>https://medium.com/@serifcolakel/observability-for-ai-agents-tracing-evaluation-and-cost-control-in-production-af02fd13c395?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/af02fd13c395</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[observability]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 18:11:28 GMT</pubDate>
            <atom:updated>2026-11-02T18:11:28.601Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9IA9R67Ie62e5nz5moUe0g.jpeg" /><figcaption>Observability for AI Agents: Tracing, Evaluation and Cost Control in Production Flow Image</figcaption></figure><p><em>How to understand, monitor, debug, and optimize agentic AI systems using OpenTelemetry and modern AI observability practices.</em></p><h3>1. Why Is Observability Different for AI Agents?</h3><p>Traditional backend observability is built around relatively predictable execution paths.</p><p>A request enters an application, passes through several services, interacts with a database, and eventually returns a response. We can use logs, metrics, and distributed traces to understand what happened and where a problem occurred.</p><p>AI agents introduce a different level of complexity.</p><p>An agent does not necessarily follow a predefined execution path. It may call an LLM multiple times, select tools dynamically, retry failed operations, delegate tasks to other agents, retrieve external context, or wait for additional user input.</p><p>Consider a relatively simple task:</p><p>A user asks an AI assistant to analyze a merge request and identify potential problems.</p><p>Behind the scenes, the application might:</p><ol><li>Receive the user’s request.</li><li>Invoke a coordinator agent.</li><li>Call an LLM to determine the next action.</li><li>Retrieve the merge request from GitLab.</li><li>Read the changed files.</li><li>Delegate security analysis to another agent.</li><li>Execute additional tool calls.</li><li>Evaluate the collected results.</li><li>Generate a final report.</li></ol><p>The user sees one response. The application has actually executed a multi-step workflow involving several components.</p><p>Now imagine the response takes 45 seconds instead of the expected 8 seconds.</p><p>Where should we investigate?</p><ul><li>Did the model take too long to respond?</li><li>Did a tool call time out?</li><li>Was the same operation executed repeatedly?</li><li>Did the agent generate unnecessary steps?</li><li>Did a subagent introduce additional latency?</li><li>Was too much context sent to the model?</li><li>Did a retry increase the overall execution time and cost?</li></ul><p>Traditional request-level logging may tell us that the endpoint took 45 seconds. It does not necessarily explain why.</p><p><strong>AI observability is about understanding the entire execution path, not just measuring the final API response.</strong></p><p>This is where OpenTelemetry becomes particularly useful.</p><h3>2. What Is OpenTelemetry?</h3><p>&lt;escape&gt;**&lt;/escape&gt;&lt;Entity value=”OpenTelemetry (OTel)” category=”software” disambig=”Open-source observability framework”/&gt; is an open-source observability framework for collecting, processing, and exporting telemetry data.</p><p>It provides a vendor-neutral instrumentation model that allows applications to generate telemetry and send it to different observability platforms.</p><p>Instead of implementing a separate monitoring integration for every vendor, we can instrument an application using OpenTelemetry and choose where the telemetry should be exported.</p><p>OpenTelemetry is built around three primary signals:</p><p>SignalWhat it tells usAI agent exampleTracesHow an operation was executedModel call → tool → subagentMetricsHow the system performs over timeToken usage, latency, error rateLogsWhat happened at a specific pointTool timeout or agent exception</p><p>These signals complement each other.</p><p>A metric can tell us that the average execution time has increased. A trace can identify which operation introduced the delay. A log can provide the error details associated with that operation.</p><p>OpenTelemetry also provides context propagation, APIs, SDKs, semantic conventions, and the OpenTelemetry Collector.</p><p>An important implementation detail: as of November 2026, the OpenTelemetry JavaScript documentation lists traces and metrics as Stable, while logs remain in Development. The maturity of a specific GenAI convention should also be evaluated separately from the maturity of the underlying OTel signal.</p><p><strong>References:</strong> <a href="https://opentelemetry.io/docs/">OpenTelemetry Documentation</a> · <a href="https://opentelemetry.io/docs/languages/js/">OpenTelemetry JavaScript</a></p><h3>3. Understanding Distributed Tracing</h3><p>Before discussing AI-specific instrumentation, we need to understand traces and spans.</p><p>A <strong>trace</strong> represents the complete execution path of an operation.</p><p>A <strong>span</strong> represents one individual unit of work within that execution.</p><p>For example:</p><pre>Trace: POST /api/agent<br>|<br>+-- HTTP Request<br>    |<br>    +-- Coordinator Agent<br>        |<br>        +-- LLM Generation<br>        |<br>        +-- Tool: GitLab - Get Merge Request<br>        |<br>        +-- Tool: GitLab - Get Changed Files<br>        |<br>        +-- Security Agent<br>        |   |<br>        |   +-- LLM Generation<br>        |   |<br>        |   +-- Tool: Security Analysis<br>        |<br>        +-- Testing Agent<br>        |   |<br>        |   +-- LLM Generation<br>        |   |<br>        |   +-- Tool: Test Coverage<br>        |<br>        +-- Final Response Generation</pre><p>Each span has information such as:</p><ul><li>Trace ID</li><li>Span ID</li><li>Parent span ID</li><li>Start and end timestamps</li><li>Duration</li><li>Attributes</li><li>Events</li><li>Status and exception information</li></ul><p>This creates a hierarchical representation of the complete execution.</p><p>The key benefit is that we can move from a high-level workflow to an individual model call or tool execution without losing the relationship between them.</p><p>For AI agents, this distinction is important because one user request can generate many independent operations.</p><h3>Why is context propagation important?</h3><p>In distributed systems, a trace often crosses multiple services.</p><p>Imagine a Next.js application calling a .NET backend, which then delegates a task to a Python agent and receives a result from another service.</p><p>Without context propagation, every service may generate its own independent trace.</p><p>We would see several unrelated operations instead of one complete workflow.</p><p>OpenTelemetry uses W3C Trace Context as its standard propagation format. The traceparent header carries trace and parent-span information between instrumented services.</p><p>The same principle applies to agent-based architectures.</p><p>When an agent delegates a task to another agent through A2A, we should preserve the correlation between the originating request and the remote execution.</p><p>Automatic HTTP instrumentation can handle propagation when the HTTP instrumentation and context manager are configured correctly. Custom transports and asynchronous message queues may require explicit context injection and extraction.</p><p>The important point is that <strong>protocol-level interoperability and observability context propagation are separate concerns</strong>. Using A2A does not, by itself, guarantee that all distributed spans will appear in one trace.</p><p><strong>References:</strong> <a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry Traces</a> · <a href="https://opentelemetry.io/docs/concepts/context-propagation/">Context Propagation</a> · <a href="https://www.w3.org/TR/trace-context/">W3C Trace Context</a></p><h3>4. OpenTelemetry and GenAI Semantic Conventions</h3><p>One of the challenges in AI observability is the lack of consistent naming and structure across different AI frameworks and providers.</p><p>For example, one framework might represent an LLM request as a generic operation, while another might expose model-specific attributes.</p><p>This makes it difficult to compare different implementations using the same monitoring platform.</p><p>OpenTelemetry addresses this through <strong>Semantic Conventions</strong>.</p><p>Semantic conventions define common attribute names, operation types, and instrumentation expectations so that telemetry from different implementations can be interpreted consistently.</p><p>The GenAI semantic conventions extend this approach to generative AI applications.</p><p>Some important attributes include:</p><p>AttributePurposegen_ai.provider.nameIdentifies the model providergen_ai.request.modelIdentifies the requested modelgen_ai.response.modelIdentifies the actual response modelgen_ai.operation.nameDescribes the operationgen_ai.usage.input_tokensInput token usagegen_ai.usage.output_tokensOutput token usagegen_ai.conversation.idAssociates operations with a conversationgen_ai.agent.nameIdentifies the agentgen_ai.tool.nameIdentifies the executed tool</p><p>For example, an LLM invocation could be represented by:</p><pre>{<br>  &quot;gen_ai.operation.name&quot;: &quot;chat&quot;,<br>  &quot;gen_ai.provider.name&quot;: &quot;openai&quot;,<br>  &quot;gen_ai.request.model&quot;: &quot;example-model&quot;,<br>  &quot;gen_ai.usage.input_tokens&quot;: 1200,<br>  &quot;gen_ai.usage.output_tokens&quot;: 350<br>}</pre><p>This is a simplified span attribute example.</p><p>The benefit is that observability platforms do not need to understand a different custom schema for every application.</p><h3>Agent-specific conventions</h3><p>The GenAI semantic conventions also define operations for agents and workflows.</p><p>Some examples include:</p><ul><li>invoke_agent</li><li>invoke_workflow</li><li>execute_tool</li><li>plan</li><li>retrieval</li></ul><p>This is particularly valuable for multi-agent architectures.</p><p>We can distinguish an individual model request from an entire agent invocation or a workflow coordinating multiple agents.</p><p>For example:</p><pre>invoke_workflow merge-request-review<br>|<br>+-- invoke_agent coordinator<br>|   |<br>|   +-- chat model<br>|   +-- execute_tool fetch_merge_request<br>|<br>+-- invoke_agent security-agent<br>|   |<br>|   +-- chat model<br>|   +-- execute_tool analyze_vulnerabilities<br>|<br>+-- invoke_agent testing-agent<br>|   |<br>|   +-- chat model<br>|   +-- execute_tool analyze_test_coverage<br>|<br>+-- Final Aggregation</pre><p>This makes the execution model much easier to understand.</p><p><strong>An important maturity consideration:</strong> As of November 2, 2026, the OpenTelemetry GenAI agent spans and GenAI metrics documents are still marked Development. These conventions are valuable design references, but teams should pin their instrumentation versions and avoid treating every proposed attribute or metric as a stable, universally implemented contract.</p><p>The current convention documents include:</p><ul><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md">Semantic Conventions for GenAI Spans</a></li><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md">Semantic Conventions for Agent Spans</a></li><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md">Semantic Conventions for GenAI Metrics</a></li></ul><h3>5. Integrating OpenTelemetry with Vercel AI SDK</h3><p>Since many TypeScript applications use the Vercel AI SDK to implement model interactions and agent workflows, it is worth looking at its native telemetry integration.</p><p>The AI SDK already supports OpenTelemetry-based instrumentation.</p><p>It can generate spans for:</p><ul><li>Text generation</li><li>Streaming generation</li><li>Individual model calls</li><li>Agent execution</li><li>Tool execution</li><li>Embedding operations</li><li>Reranking</li></ul><p>This means we do not need to manually instrument every model call and tool execution just to obtain basic execution visibility.</p><h3>Step 1: Install the packages</h3><p>For an AI SDK v7 application:</p><pre>pnpm add @ai-sdk/otel @vercel/otel</pre><p>The OpenTelemetry integration is provided through @ai-sdk/otel.</p><p>This is an important version-specific detail.</p><p>In AI SDK v7, OpenTelemetry integration moved out of the core ai package. Older examples using the v6 experimental_telemetry configuration should not be copied directly into a v7 application.</p><h3>Step 2: Register telemetry in Next.js</h3><p>Create an instrumentation.ts file in the project root.</p><pre>import { registerOTel } from &#39;@vercel/otel&#39;;<br>import { registerTelemetry } from &#39;ai&#39;;<br>import { OpenTelemetry } from &#39;@ai-sdk/otel&#39;;<br><br>const serviceName = &#39;ai-agent-platform&#39;<br><br>export function register() {<br><br>  registerOTel({<br>    serviceName,<br>  });<br><br>  registerTelemetry(<br>    new OpenTelemetry({<br>      recordInputs: false,<br>      recordOutputs: false,<br>    }),<br>  );<br>}</pre><p>There are two responsibilities here:</p><ul><li>registerOTel initializes the OpenTelemetry infrastructure for the Next.js application.</li><li>registerTelemetry registers the AI SDK-specific integration that creates GenAI spans.</li></ul><p>I have disabled input and output capture in this example intentionally.</p><p>AI SDK telemetry can record prompts and generated outputs by default. In an enterprise environment, capturing this data requires an explicit privacy and security decision.</p><p>We will return to this in the security section.</p><p>For a regular Node.js application without Next.js, register the OpenTelemetry integration once at application startup.</p><h3>Step 3: Instrument AI SDK calls</h3><p>Once telemetry is registered, supported AI SDK operations automatically emit telemetry through the registered integration.</p><p>We can also provide a functionId to identify the business operation or agent.</p><pre>import { generateText } from &#39;ai&#39;;<br><br>const result = await generateText({<br>  model: myModel,<br><br>  instructions: `<br>    You are a financial analysis assistant.<br>    Analyze the supplied information carefully.<br>  `,<br><br>  prompt: &#39;Analyze the latest portfolio performance.&#39;,<br><br>  telemetry: {<br>    functionId: &#39;portfolio-analysis-agent&#39;,<br>  },<br>});</pre><p>The functionId helps distinguish operations in traces.</p><p>For example, rather than seeing only generic model invocation spans, we can identify a specific business operation such as:</p><pre>invoke_agent portfolio-analysis-agent</pre><h3>Step 4: Understand the generated spans</h3><p>The AI SDK v7 OpenTelemetry integration follows the GenAI semantic conventions.</p><p>For generateText and streamText, the integration records three main types of spans:</p><p><strong>1. Agent invocation span</strong></p><p>The root span represents the complete generation operation, including its execution steps and tool calls.</p><p>Example:</p><pre>invoke_agent portfolio-analysis-agent</pre><p><strong>2. Step and model-call spans</strong></p><p>Each model interaction is represented by step and provider-call spans.</p><p>These allow us to understand how many model calls occurred during an operation and how long each one took.</p><p><strong>3. Tool execution spans</strong></p><p>Each client-side tool execution receives its own span.</p><p>For example:</p><pre>invoke_agent portfolio-analysis-agent<br>|<br>+-- step 0<br>|   |<br>|   +-- chat example-model<br>|   +-- execute_tool get_portfolio<br>|<br>+-- step 1<br>|   |<br>|   +-- chat example-model<br>|   +-- execute_tool get_market_data<br>|<br>+-- step 2<br>    |<br>    +-- chat example-model</pre><p>The resulting trace provides a much more complete representation of an agent workflow than a single model request log.</p><p>The integration also records usage information such as input tokens, output tokens, cache usage, model identifiers, and operation duration when these values are available.</p><h3>Step 5: Add additional application context</h3><p>Sometimes the standard GenAI attributes are not enough.</p><p>For example, we may want to know which business workflow initiated an operation or which application component owns it.</p><p>The AI SDK provides enrichSpan for adding custom attributes:</p><pre>import { registerTelemetry } from &#39;ai&#39;;<br>import { OpenTelemetry } from &#39;@ai-sdk/otel&#39;;<br><br>registerTelemetry(<br>  new OpenTelemetry({<br>    recordInputs: false,<br>    recordOutputs: false,<br>    enrichSpan: ({ spanType, operationId }) =&gt; ({<br>      &#39;app.component&#39;: &#39;portfolio-assistant&#39;,<br>      &#39;app.operation_id&#39;: operationId,<br>      &#39;app.span_type&#39;: spanType,<br>    }),<br>  }),<br>);</pre><p>The exact context available to enrichSpan depends on the span and the SDK&#39;s integration API.</p><p>The objective is to enrich the trace with useful application-level context without copying sensitive business data into telemetry.</p><p><strong>References:</strong> <a href="https://ai-sdk.dev/docs/ai-sdk-core/telemetry">AI SDK Telemetry</a> · <a href="https://nextjs.org/docs/app/guides/open-telemetry">Next.js OpenTelemetry</a> · <a href="https://github.com/vercel/ai/blob/main/skills/migrate-ai-sdk-v6-to-v7/SKILL.md">AI SDK v7 Migration Guide</a></p><h3>6. Instrumenting Your Own Agent Runtime</h3><p>Built-in instrumentation is useful, but it does not automatically understand every application-level decision.</p><p>For example, an AI SDK trace may show model calls and tool executions, but it may not tell us why a coordinator selected a particular specialist agent or how long a business workflow spent waiting for an approval.</p><p>These are application-level operations.</p><p>We should instrument them explicitly.</p><p>OpenTelemetry provides a tracing API for creating custom spans.</p><p>Consider a custom agent orchestration function:</p><pre>import {<br>  trace,<br>  SpanStatusCode,<br>} from &#39;@opentelemetry/api&#39;;<br><br>const tracer = trace.getTracer(&#39;agent-orchestrator&#39;);<br><br>export async function runReviewWorkflow() {<br>  return tracer.startActiveSpan(<br>    &#39;workflow.merge-request-review&#39;,<br>    async (span) =&gt; {<br>      span.setAttribute(<br>        &#39;workflow.name&#39;,<br>        &#39;merge-request-review&#39;,<br>      );<br>      span.setAttribute(<br>        &#39;workflow.version&#39;,<br>        &#39;1.0.0&#39;,<br>      );<br><br>      try {<br>        const result = await executeReview();<br>        span.setStatus({<br>          code: SpanStatusCode.OK,<br>        });<br>        return result;<br>      } catch (cause) {<br>        const error =<br>          cause instanceof Error<br>            ? cause<br>            : new Error(String(cause));<br>        span.recordException(error);<br>        span.setStatus({<br>          code: SpanStatusCode.ERROR,<br>        });<br>        throw error;<br>      } finally {<br>        span.end();<br>      }<br><br>    },<br>  );<br>}</pre><p>Any supported AI SDK operations executed inside this active span can become child operations in the same trace, provided the OpenTelemetry context is correctly propagated.</p><p>We now have two levels of observability:</p><ul><li>Framework-level instrumentation describes model and tool execution.</li><li>Application-level instrumentation describes business workflows and orchestration decisions.</li></ul><p>This distinction is important.</p><p>We should not expect a model instrumentation library to understand the complete business meaning of an application request.</p><h3>Instrumenting remote agent calls</h3><p>For A2A-based systems, distributed tracing becomes even more important.</p><p>Imagine a coordinator running in one service and a security agent running in another service.</p><p>The coordinator should create a child operation for the delegation. The remote service should continue the trace when the execution context is propagated.</p><p>Conceptually:</p><pre>Trace: merge-request-review<br>|<br>+-- Coordinator Agent<br>|   |<br>|   +-- Model Call<br>|   |<br>|   +-- A2A: Delegate to Security Agent<br>|       |<br>|       +-- Security Agent<br>|           |<br>|           +-- Model Call<br>|           |<br>|           +-- Tool Execution<br>|<br>+-- Final Aggregation</pre><p>The HTTP request must carry the appropriate trace context, typically through W3C traceparent and tracestate headers.</p><p>For custom transports or asynchronous queues, explicitly inject and extract the OpenTelemetry context.</p><p>This is how we avoid fragmented traces across independently deployed agents.</p><h3>7. What Should We Measure?</h3><p>Collecting telemetry is not enough.</p><p>We need to define which metrics are useful for understanding the health and quality of an agent platform.</p><p>I would divide AI observability metrics into five categories.</p><h3>7.1. Latency Metrics</h3><p>These describe how long operations take.</p><p>MetricWhy it mattersEnd-to-end workflow durationMeasures total user-facing execution timeModel request durationIdentifies slow inference callsTime to first tokenMeasures perceived response latencyTool execution durationIdentifies slow external operationsAgent invocation durationMeasures the execution time of one agentQueue wait durationIdentifies scheduling bottlenecks</p><p>For streaming applications, time to first token is particularly useful.</p><p>A workflow that takes 20 seconds but starts responding after 300 ms may provide a better experience than one that takes 10 seconds and remains silent for the first 8 seconds.</p><h3>7.2. Reliability Metrics</h3><p>These help us understand whether workflows complete successfully.</p><p>Examples:</p><ul><li>Agent execution success rate</li><li>Tool execution failure rate</li><li>Model request failure rate</li><li>Timeout rate</li><li>Retry rate</li><li>Task cancellation rate</li><li>Human intervention rate</li><li>Incomplete workflow rate</li></ul><p>A high retry rate can be an early indicator of a problematic integration or an unstable external dependency.</p><p>We should also distinguish between a successful model response and a successful business workflow.</p><p>An LLM can return a perfectly valid response while the overall task fails to achieve its objective.</p><h3>7.3. Token and Model Usage</h3><p>Token usage should be tracked at multiple levels.</p><p>MetricDescriptionInput tokensTokens sent to the modelOutput tokensTokens generated by the modelCached input tokensTokens served from provider cacheReasoning tokensModel-reported reasoning token usageTokens per workflowTotal usage across an entire taskTokens per agentUsage attributed to an individual agent</p><p>For multi-step workflows, we should aggregate usage from all model calls rather than looking only at the final response.</p><h3>7.4. Agent and Tool Behavior</h3><p>These metrics describe how efficiently agents perform their work.</p><p>Examples:</p><ul><li>Number of tool calls per task</li><li>Number of model calls per agent invocation</li><li>Number of agent handoffs</li><li>Repeated tool-call frequency</li><li>Tool-selection success</li><li>Average steps per workflow</li><li>Average number of clarification requests</li></ul><p>An agent repeatedly calling the same tool can indicate a reasoning loop, an incorrect tool result, or an orchestration problem.</p><p>The trace helps us identify the execution pattern. Metrics help us understand how frequently the pattern occurs.</p><h3>7.5. Quality Metrics</h3><p>This is where AI observability starts to overlap with AI evaluation.</p><p>Examples:</p><ul><li>Answer correctness</li><li>Groundedness</li><li>Retrieval relevance</li><li>Tool-selection accuracy</li><li>Task completion</li><li>Policy adherence</li><li>Human acceptance rate</li></ul><p>These metrics cannot always be derived from execution telemetry alone.</p><p>We need an evaluation strategy to measure the actual quality of the result.</p><h3>8. Cost Control: Understanding the Real Cost of an Agent</h3><p>One of the biggest mistakes in AI platforms is measuring only model token usage and treating it as the total cost of the workflow.</p><p>An agent-based application may consume resources across several layers:</p><ul><li>LLM inference</li><li>Embedding generation</li><li>Reranking</li><li>External API calls</li><li>Database queries</li><li>Vector database operations</li><li>Container or sandbox execution</li><li>Remote agent invocations</li><li>Infrastructure and networking</li></ul><p>The total cost should reflect the entire execution path.</p><p>A useful starting point is to define a model cost formula:</p><p>[ C<em>{\text{model}} = \sum</em>{i=1}^{n} \left( T<em>{\text{input},i}P</em>{\text{input},i} + T<em>{\text{output},i}P</em>{\text{output},i} \right) ]</p><p>Where:</p><ul><li>(T) represents token usage.</li><li>(P) represents the applicable price per token.</li><li>(i) represents an individual model operation.</li></ul><p>The pricing model may also need separate rates for cached input, audio, images, reasoning tokens, or other modalities.</p><p>We should use the actual provider-reported billable usage wherever available and avoid double-counting cached or reasoning token subsets.</p><p>The complete workflow cost can then be represented as:</p><p>[ C<em>{\text{workflow}} = C</em>{\text{model}} + C<em>{\text{tools}} + C</em>{\text{compute}} + C_{\text{storage}} ]</p><p>This may be an estimate rather than an exact invoice calculation, depending on which provider and infrastructure usage data are available.</p><h3>Why is cost per successful task more useful?</h3><p>Imagine two implementations:</p><p>MetricAgent AAgent BAverage cost per attempt$0.04$0.07Successful completion rate90%98%Cost per successful task~$0.044~$0.071</p><p>Agent A is cheaper per attempt, but Agent B completes more tasks successfully.</p><p>Neither is automatically better.</p><p>The correct decision depends on the business value of successful completion, quality, latency, and the cost of failed attempts.</p><p>A useful metric is:</p><p>[ \text{Cost per successful task} = \frac{\text{Total execution cost}} {\text{Number of successful tasks}} ]</p><p>The numerator should include failed attempts that consumed resources, rather than counting only successful runs.</p><h3>Model selection and routing</h3><p>Observability data can also help us make better model-routing decisions.</p><p>For example:</p><ul><li>Use a smaller model for classification or simple extraction.</li><li>Use a more capable model for complex analysis.</li><li>Avoid unnecessary model calls when a deterministic operation is sufficient.</li><li>Reuse cached context when appropriate.</li><li>Set explicit iteration and tool-call limits.</li><li>Stop an agent when a task exceeds its budget.</li></ul><p>The important principle is that model selection should be based on measured task quality and total execution cost, not just the price of an individual inference request.</p><h3>Token metrics in OpenTelemetry</h3><p>The current GenAI token metric conventions include proposed counters such as:</p><ul><li>gen_ai.client.inference.usage.input_tokens</li><li>gen_ai.client.inference.usage.output_tokens</li><li>gen_ai.client.inference.usage.cache_read.input_tokens</li><li>gen_ai.client.inference.usage.reasoning.output_tokens</li></ul><p>These conventions are still in Development.</p><p>There is also an important distinction between token usage attributes on individual spans and cumulative token usage metrics.</p><p>A span attribute describes an individual operation. A counter or histogram is used to analyze consumption across many operations.</p><p>For production cost reporting, maintain an application-level pricing registry with model and provider versions, effective dates, and the relevant unit prices.</p><p>This makes historical cost calculations more reliable when provider pricing changes.</p><p><strong>References:</strong> <a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-token-metrics.md">GenAI Token Metrics</a> · <a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md">GenAI Metrics</a></p><h3>9. Observability Is Not Evaluation</h3><p>This distinction is worth emphasizing.</p><p>Observability tells us what happened during execution.</p><p>Evaluation tells us whether the result was good enough.</p><p>A trace can show that an agent:</p><ul><li>Called the correct model.</li><li>Used three tools.</li><li>Retrieved five documents.</li><li>Completed within four seconds.</li><li>Returned a response without an exception.</li></ul><p>But none of these facts proves that the answer was correct.</p><p>Consider a RAG application.</p><p>The retrieval operation may complete successfully, and the model may return a fluent answer. However, the retrieved documents may be irrelevant or the final answer may contain unsupported information.</p><p>From an operational perspective, the request succeeded.</p><p>From a quality perspective, it failed.</p><p>We need both observability and evaluation.</p><h3>Offline Evaluation</h3><p>Offline evaluation measures an AI application against a predefined dataset.</p><p>For example, we can create 100 representative questions and expected outcomes.</p><p>Whenever we modify a prompt, model, retrieval strategy, or agent workflow, we run the same dataset again.</p><p>This helps identify regressions before deploying changes.</p><p>Useful evaluation dimensions include:</p><ul><li>Answer correctness</li><li>Relevance</li><li>Groundedness</li><li>Tool selection</li><li>Expected task completion</li><li>Policy compliance</li></ul><h3>Online Evaluation</h3><p>Online evaluation measures actual production interactions.</p><p>We can select a sample of production traces and evaluate them using:</p><ul><li>Rule-based checks</li><li>Code-based evaluators</li><li>LLM-as-a-Judge</li><li>Human feedback</li></ul><p>The important part is to turn real production failures into future test cases.</p><p>For example:</p><ol><li>A production trace shows that an agent selected the wrong tool.</li><li>We identify the relevant input and expected behavior.</li><li>We create a regression dataset entry.</li><li>We update the prompt or agent implementation.</li><li>We rerun offline evaluations.</li><li>We deploy and monitor the next set of production traces.</li></ol><p>This creates a continuous improvement loop.</p><pre>Production Traces<br>       |<br>       v<br>Failure Analysis<br>       |<br>       v<br>Evaluation Dataset<br>       |<br>       v<br>Offline Evaluation<br>       |<br>       v<br>Prompt / Model / Agent Changes<br>       |<br>       v<br>Deployment<br>       |<br>       v<br>Online Evaluation<br>       |<br>       +------&gt; New Regression Cases</pre><h3>Tools worth evaluating</h3><p>Two examples of AI-focused observability and evaluation platforms are:</p><p><strong>Langfuse</strong></p><p>Provides tracing, evaluations, datasets, experiments, and online evaluation of production traffic. It can help connect execution traces with quality measurements.</p><p>Documentation: <a href="https://langfuse.com/docs/evaluation/overview">https://langfuse.com/docs/evaluation/overview</a></p><p><strong>Arize Phoenix</strong></p><p>An open-source observability and evaluation platform built around OpenTelemetry-based instrumentation and OpenInference conventions.</p><p>It provides tracing, evaluation, datasets, experiments, and debugging capabilities.</p><p>Documentation: <a href="https://phoenix.arize.com/">https://phoenix.arize.com/</a></p><p>These tools complement OpenTelemetry. They do not replace the underlying need for correct instrumentation or a well-defined evaluation strategy.</p><p><strong>Reference:</strong> <a href="https://langfuse.com/docs/evaluation/core-concepts">Langfuse Evaluation Concepts</a> · <a href="https://github.com/Arize-ai/phoenix">Arize Phoenix</a></p><h3>10. The Role of OpenTelemetry Collector</h3><p>In a production architecture, applications should not necessarily export telemetry directly to every observability backend.</p><p>This is where the OpenTelemetry Collector becomes useful.</p><p>The Collector receives telemetry from instrumented applications, processes it, and exports it to one or more destinations.</p><p>It provides a central place for:</p><ul><li>Batching telemetry</li><li>Filtering unwanted data</li><li>Applying processors</li><li>Handling authentication</li><li>Reducing telemetry volume</li><li>Sampling traces</li><li>Routing signals to different backends</li></ul><p>A simplified architecture looks like this:</p><pre>                AI Applications<br>                       |<br>            +----------+----------+<br>            |          |          |<br>            v          v          v<br>         Next.js     .NET API   Python Agent<br>            |          |          |<br>            +----------+----------+<br>                       |<br>                      OTLP<br>                       |<br>                       v<br>             OpenTelemetry Collector<br>                       |<br>              +--------+--------+<br>              |        |        |<br>              v        v        v<br>            Traces   Metrics   Logs<br>              |        |        |<br>              v        v        v<br>            Jaeger   Prometheus  Log Backend<br>              |<br>              v<br>            Grafana</pre><p>The same telemetry can also be routed to AI-specific observability platforms where supported.</p><h3>Why centralize collection?</h3><p>In enterprise environments, we may have several applications and teams using different frameworks and languages.</p><p>A Collector provides a common ingestion point and makes it possible to apply centralized policies.</p><p>For example, a Collector can filter sensitive attributes before telemetry reaches the final backend.</p><p>It also allows us to change exporters or observability providers without changing the instrumentation in every application.</p><h3>Agent and gateway deployment</h3><p>OpenTelemetry supports different Collector deployment patterns.</p><p>A common enterprise approach is to run lightweight Collectors close to applications and forward telemetry to centralized gateway Collectors.</p><p>This provides a separation between local telemetry collection and centralized processing.</p><p>However, it introduces operational complexity and additional infrastructure. The Collector itself becomes another production component that needs capacity planning, monitoring, and failure handling.</p><p><strong>Reference:</strong> <a href="https://opentelemetry.io/docs/collector/">OpenTelemetry Collector</a> · <a href="https://opentelemetry.io/docs/collector/deploy/gateway/">Gateway Deployment Pattern</a></p><h3>11. Sampling: How Do We Control Telemetry Volume?</h3><p>Agent-based workflows can generate a significant amount of telemetry.</p><p>Consider an agent that performs 15 model calls and 20 tool calls during a single task.</p><p>If the application handles thousands of tasks per hour, storing every span indefinitely may become expensive.</p><p>Sampling helps control this volume.</p><p>There are two common approaches.</p><h3>Head-Based Sampling</h3><p>The sampling decision is made when the trace starts.</p><p>For example, we may decide to sample 10% of incoming requests.</p><p>This is relatively simple and reduces the amount of telemetry generated or exported.</p><p>However, we do not yet know whether a trace will eventually fail or become unusually slow.</p><h3>Tail-Based Sampling</h3><p>The sampling decision is made after considering the trace’s collected spans.</p><p>This makes it possible to retain:</p><ul><li>All failed traces.</li><li>All traces exceeding a latency threshold.</li><li>Traces containing specific tool failures.</li><li>A percentage of successful traces.</li></ul><p>For AI agents, tail-based sampling can be particularly useful because many execution problems only become visible after several operations have completed.</p><p>However, tail sampling has an important infrastructure requirement: all spans belonging to the same trace must reach the same tail-sampling Collector instance.</p><p>This generally requires trace-ID-aware routing when using multiple Collector instances.</p><p>A practical approach is to combine sampling strategies:</p><ul><li>Preserve error and high-latency traces.</li><li>Retain a representative sample of successful traces.</li><li>Maintain complete aggregate metrics where possible.</li><li>Apply stricter retention policies to verbose telemetry.</li></ul><p><strong>Reference:</strong> <a href="https://opentelemetry.io/docs/concepts/sampling/">OpenTelemetry Sampling</a> · <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor">Tail Sampling Processor</a></p><h3>12. Security and Privacy Considerations</h3><p>This is one of the most important aspects of AI observability in enterprise environments.</p><p>Traditional telemetry usually contains operational information such as request duration, HTTP status codes, and service names.</p><p>AI telemetry can contain much more sensitive information.</p><p>For example:</p><ul><li>User prompts</li><li>System instructions</li><li>Generated responses</li><li>Tool arguments</li><li>Tool results</li><li>Retrieved documents</li><li>Conversation context</li><li>Customer or business identifiers</li></ul><p>Sending all this information to an observability backend can create additional privacy and data governance risks.</p><h3>Do not capture prompts and responses by default</h3><p>A particularly important detail is that the AI SDK OpenTelemetry integration records inputs and outputs by default unless configured otherwise.</p><p>We can disable this behavior:</p><pre>registerTelemetry(<br>  new OpenTelemetry({<br>    recordInputs: false,<br>    recordOutputs: false,<br>  }),<br>);</pre><p>For many production applications, this is a better starting point.</p><p>We can still collect useful operational telemetry:</p><ul><li>Model identifiers</li><li>Token counts</li><li>Duration</li><li>Finish reasons</li><li>Tool execution times</li><li>Error information</li><li>Workflow identifiers</li></ul><p>without exporting the complete conversation.</p><h3>Apply data minimization</h3><p>Before exporting telemetry, ask whether each attribute is genuinely needed.</p><p>Avoid sending:</p><ul><li>Access tokens</li><li>Authorization headers</li><li>Passwords</li><li>Raw customer records</li><li>Full database query results</li><li>Unfiltered tool arguments</li></ul><p>For sensitive workflows, use approved metadata rather than full payloads.</p><h3>Separate operational telemetry from application data</h3><p>An observability backend should not automatically become another storage system for business data.</p><p>The fact that a trace is useful for debugging does not mean the full underlying request and response should be retained indefinitely.</p><h3>Define access and retention policies</h3><p>Enterprise environments should define:</p><ul><li>Who can access traces.</li><li>Which teams can view prompt and response content.</li><li>How long telemetry is retained.</li><li>How data is deleted.</li><li>Which environments are allowed to capture full content.</li><li>How telemetry exports are authenticated and encrypted.</li></ul><p>A sensible approach is to use more detailed content capture in controlled development environments and much stricter policies in production.</p><p><strong>Reference:</strong> <a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md">OpenTelemetry GenAI Content Capture</a></p><h3>13. Limitations and Trade-offs</h3><p>OpenTelemetry provides a strong foundation, but it is not a complete AI observability solution by itself.</p><h3>Instrumentation coverage varies</h3><p>Not every framework, provider, or agent runtime exposes the same information.</p><p>Some integrations can automatically trace model calls and tool execution, while others require custom instrumentation.</p><p>We should verify what is actually captured rather than assuming that installing an OpenTelemetry package provides complete visibility.</p><h3>GenAI semantic conventions are evolving</h3><p>The GenAI conventions are still under development.</p><p>Attribute names, operation models, and implementation support may evolve.</p><p>Teams should pin versions, review changes, and avoid coupling application business logic to conventions that have not reached the required maturity.</p><h3>Observability does not guarantee answer quality</h3><p>A trace can explain what happened but does not prove that the model’s result is correct.</p><p>Evaluation datasets, deterministic checks, and human feedback remain necessary.</p><h3>Telemetry has its own cost</h3><p>Collecting every input, output, tool argument, and execution event can generate significant storage and processing overhead.</p><p>We should measure the cost of observability itself.</p><h3>High-cardinality dimensions can become expensive</h3><p>Adding unique user IDs, conversation IDs, trace IDs, or request IDs to metric labels can create an excessive number of time series.</p><p>Use traces for request-level correlation and keep metric dimensions bounded.</p><h3>Distributed tracing requires correct context propagation</h3><p>A2A communication, queues, background tasks, and remote tool execution may cross process boundaries.</p><p>If context is lost, we can end up with disconnected traces.</p><h3>AI evaluation requires careful design</h3><p>LLM-as-a-Judge can provide useful signals, but it can also be inconsistent or biased.</p><p>A single evaluation score should not become the only definition of quality.</p><p>Combine deterministic checks, representative datasets, human review, and calibrated evaluators.</p><h3>14. What Can an AI Chapter Lead Contribute?</h3><p>I think this is where observability moves from an implementation detail to an engineering capability.</p><p>When multiple teams develop AI-powered applications, each team may implement different logging, tracing, and evaluation mechanisms.</p><p>Over time, this creates fragmented visibility.</p><p>One team measures token usage, another measures response latency, and another stores tool-call logs without a common correlation model.</p><p>As an AI Chapter Lead, I would focus on establishing a shared observability standard that can be reused across teams and applications.</p><h3>1. Define a Common Telemetry Standard</h3><p>Establish a baseline for the telemetry every agent-based application should produce.</p><p>For example:</p><p>CategoryRequired informationApplicationService name, environment, versionWorkflowWorkflow name, workflow versionAgentAgent name, execution statusModelProvider, requested model, response modelUsageInput/output tokens, cache usageToolsTool name, duration, execution statusExecutionTrace ID, span ID, error informationBusiness outcomeCompleted, failed, canceled, escalated</p><p>The objective is to make observability consistent without forcing every team to use the same agent framework.</p><h3>2. Build a Shared Instrumentation Package</h3><p>We can create an internal TypeScript package that wraps the common OpenTelemetry configuration.</p><p>For example:</p><pre>@company/ai-observability<br>|<br>+-- OpenTelemetry initialization<br>+-- AI SDK integration<br>+-- Agent span utilities<br>+-- Workflow instrumentation<br>+-- Tool execution tracking<br>+-- Cost attribution<br>+-- Evaluation metadata<br>+-- Privacy and redaction policies</pre><p>This package can provide standard helpers for application-level tracing and reduce duplicated implementation.</p><p>For teams using .NET, Python, or Go, equivalent instrumentation conventions can be established without requiring identical SDK implementations.</p><h3>3. Standardize Agent and Workflow Metrics</h3><p>We should define a common set of metrics that helps teams understand both technical performance and business outcomes.</p><p>Examples:</p><ul><li>Agent success rate</li><li>Workflow completion rate</li><li>Tool-call failure rate</li><li>Model latency</li><li>Cost per successful task</li><li>Average token usage</li><li>Human intervention rate</li><li>Evaluation quality scores</li></ul><p>A shared dashboard can make it easier to compare systems and identify performance regressions.</p><h3>4. Establish an Internal Evaluation Framework</h3><p>Observability without evaluation gives us only part of the picture.</p><p>We can create reusable evaluation datasets and testing patterns for common agent behaviors.</p><p>For example:</p><ul><li>Tool selection accuracy</li><li>Missing-source detection</li><li>Correct handling of incomplete input</li><li>Permission violations</li><li>Unsupported claims</li><li>Failure recovery</li><li>Task completion quality</li></ul><p>This is particularly valuable because agent behavior can change when prompts, tools, models, or orchestration logic are modified.</p><h3>5. Establish Privacy and Data Governance</h3><p>Define what can and cannot be captured by default.</p><p>The shared implementation should make safe defaults easy to use and prevent teams from accidentally exporting sensitive prompts, responses, or tool payloads.</p><p>This should include clear ownership, access policies, retention, and audit requirements.</p><h3>6. Run a Cross-Team Pilot</h3><p>A useful pilot would be a multi-agent workflow that involves several independently deployed services.</p><p>For example, a merge request review involving coordinator, security, and testing agents.</p><p>The pilot should demonstrate:</p><ol><li>One trace across the complete request.</li><li>Parent-child relationships between agents.</li><li>Model and tool execution spans.</li><li>Token and cost attribution.</li><li>Error and retry visibility.</li><li>Evaluation results associated with the completed workflow.</li></ol><p>This would provide a reference architecture that other teams can reuse.</p><h3>7. Make Observability Part of the Development Lifecycle</h3><p>Observability should not begin after production deployment.</p><p>We can introduce telemetry and evaluation requirements into the development process itself.</p><p>For example:</p><ul><li>New agents must have execution tracing.</li><li>New tools must expose execution status and duration.</li><li>Prompt changes must pass relevant regression evaluations.</li><li>New workflows must define success and failure criteria.</li><li>Production deployments must have basic operational dashboards.</li></ul><p>This makes observability a standard engineering practice rather than a separate monitoring task.</p><h3>15. A Practical Adoption Roadmap</h3><p>I would avoid introducing every observability component at once.</p><p>A phased approach is more useful.</p><p>PhaseFocusExpected result1OpenTelemetry initializationBasic traces and metrics2AI SDK instrumentationModel and tool visibility3Custom agent spansComplete workflow tracing4Cost attributionUsage and cost per task5EvaluationOffline and online quality checks6Security and governancePrivacy and access controls7Shared dashboardsCross-team operational visibility</p><p>The first milestone should be simple:</p><p><strong>For every important agent workflow, we should be able to answer what happened, how long it took, how much it cost, and whether it achieved the intended result.</strong></p><p>Once this foundation is established, more advanced capabilities such as anomaly detection, adaptive sampling, automated evaluation, and cost-aware model routing become much easier to introduce.</p><h3>Conclusion</h3><p>AI agents introduce a new level of complexity to application observability.</p><p>A single user request can trigger multiple model calls, tool executions, retrieval operations, and remote agent interactions. Monitoring only the HTTP request or final model response is not enough to understand the complete execution.</p><p>OpenTelemetry provides a vendor-neutral foundation for collecting traces, metrics, and logs across these components.</p><p>Combined with GenAI semantic conventions, AI SDK instrumentation, distributed tracing, and dedicated evaluation systems, it gives engineering teams a much better understanding of how agent-based applications behave in production.</p><p>But the most important distinction remains:</p><p><strong>Observability tells us what happened. Evaluation tells us whether it was good. Cost attribution tells us whether it was worth it.</strong></p><p>For an AI Chapter Lead, the opportunity is to make these capabilities part of the engineering standard: shared instrumentation, consistent metrics, measurable quality, and secure telemetry practices.</p><p>The goal should not be to collect more telemetry.</p><p>It should be to make every production agent workflow understandable, measurable, and continuously improvable.</p><h3>References and Further Reading</h3><h3>OpenTelemetry</h3><ol><li><a href="https://opentelemetry.io/docs/">OpenTelemetry Official Documentation</a> — Core concepts, signals, instrumentation, and architecture.</li><li><a href="https://opentelemetry.io/docs/languages/js/">OpenTelemetry JavaScript</a> — TypeScript and Node.js instrumentation.</li><li><a href="https://opentelemetry.io/docs/concepts/signals/traces/">OpenTelemetry Tracing</a> — Traces, spans, and distributed execution.</li><li><a href="https://opentelemetry.io/docs/concepts/context-propagation/">OpenTelemetry Context Propagation</a> — Distributed trace context.</li><li><a href="https://opentelemetry.io/docs/collector/">OpenTelemetry Collector</a> — Receiving, processing, and exporting telemetry.</li><li><a href="https://opentelemetry.io/docs/collector/deploy/gateway/">OpenTelemetry Gateway Deployment</a> — Centralized telemetry collection.</li><li><a href="https://opentelemetry.io/docs/concepts/sampling/">OpenTelemetry Sampling</a> — Head-based and tail-based sampling.</li></ol><h3>GenAI Semantic Conventions</h3><ol><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md">GenAI Semantic Conventions — Spans</a> — Model operations, retrieval, and tool execution.</li><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md">GenAI Agent Spans</a> — Agent invocation, workflows, and tool spans.</li><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md">GenAI Metrics</a> — Agent duration, tool calls, and workflow metrics.</li><li><a href="https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-token-metrics.md">GenAI Token Metrics</a> — Token consumption and usage measurements.</li></ol><h3>Vercel AI SDK</h3><ol><li><a href="https://ai-sdk.dev/docs/ai-sdk-core/telemetry">AI SDK Telemetry</a> — OpenTelemetry integration, span hierarchy, and custom attributes.</li><li><a href="https://github.com/vercel/ai/blob/main/skills/migrate-ai-sdk-v6-to-v7/SKILL.md">AI SDK v7 Migration Guide</a> — Version-specific telemetry changes.</li><li><a href="https://nextjs.org/docs/app/guides/open-telemetry">Next.js OpenTelemetry</a> — Application-level instrumentation.</li></ol><h3>AI Evaluation and Observability Platforms</h3><ol><li><a href="https://langfuse.com/docs/evaluation/overview">Langfuse Evaluation</a> — Online and offline evaluation.</li><li><a href="https://langfuse.com/docs/evaluation/core-concepts">Langfuse Core Concepts</a> — Datasets, experiments, and evaluators.</li><li><a href="https://github.com/Arize-ai/phoenix">Arize Phoenix</a> — Open-source AI observability and evaluation.</li><li><a href="https://github.com/Arize-ai/openinference">OpenInference</a> — OpenTelemetry-based instrumentation conventions for AI applications.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=af02fd13c395" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hedged Requests in Go: Reducing Tail Latency Without Overloading Dependencies]]></title>
            <link>https://alt.ctrlmag.com/hedged-requests-in-go-reducing-tail-latency-without-overloading-dependencies-8ce18e1c1a8e?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/8ce18e1c1a8e</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 17:29:01 GMT</pubDate>
            <atom:updated>2026-11-02T18:48:43.155Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4HSg9pq77tLxHP1s6sm74Q.png" /><figcaption>Hedged Requests in Go: Reducing Tail Latency Without Overloading Dependencies</figcaption></figure><p>Most requests in a distributed system finish quickly.</p><p>A few don’t.</p><p>Your API might have a median response time of 40ms, while its p99 latency reaches 900ms. Most users get a fast response, but a small percentage experience significantly longer waits.</p><p>These slow requests are particularly problematic in systems that depend on multiple services.</p><p>A single slow dependency can delay an entire operation, even when every other component is responding normally.</p><p>One way to address this problem is <strong>request hedging</strong>.</p><p>Instead of waiting indefinitely for an unusually slow request, the client starts another copy after a configured delay. Whichever request succeeds first wins, and the remaining requests are canceled.</p><p>This technique can significantly reduce tail latency, but it comes with an important trade-off: it generates additional traffic.</p><p>If every slow request triggers another request without limits, the mechanism designed to improve latency can overload the dependency it’s trying to protect.</p><p>In this article, we’ll explore:</p><ul><li>What tail latency is and why averages hide it</li><li>How hedged requests work</li><li>The difference between hedging and retries</li><li>How to implement hedged requests in Go</li><li>How to cancel losing requests safely</li><li>How to integrate hedging with retry budgets and adaptive concurrency</li><li>When hedging helps and when it makes things worse</li><li>What to monitor and test in production</li></ul><h3>1. The Problem: Tail Latency</h3><p>Let’s say an API receives 10,000 requests.</p><p>Its response-time distribution looks like this:</p><p>PercentileResponse timep5040msp9075msp95120msp99900msp99.92.5s</p><p>Looking only at the average or median would suggest that the service is performing well.</p><p>But 1% of 10,000 requests means 100 requests take at least 900ms.</p><p>For latency-sensitive applications, these outliers matter.</p><p>Consider an API that aggregates data from five independent services:</p><pre>                  API Request<br>                      |<br>          +-----------+-----------+<br>          |           |           |<br>       Service A  Service B   Service C<br>          |           |           |<br>       Service D  Service E       |<br>          |           |           |<br>          +-----------+-----------+<br>                      |<br>                 Final Response</pre><p>If the API needs all five responses, its total latency is constrained by the slowest dependency.</p><p>Even if four services respond in 30ms, one service taking 800ms can delay the entire operation.</p><p>This is one reason tail latency becomes increasingly important as distributed systems grow.</p><p>Jeff Dean and Luiz André Barroso discuss this problem in their influential paper, <em>The Tail at Scale</em>. They describe techniques for building responsive services despite unpredictable latency in individual components.</p><p>Reference: <a href="https://research.google/pubs/the-tail-at-scale/">https://research.google/pubs/the-tail-at-scale/</a></p><h3>2. What Are Hedged Requests?</h3><p>A hedged request is a duplicate request sent while the original request is still in progress.</p><p>The client does not immediately send multiple copies.</p><p>Instead, it waits for a configurable period.</p><p>If the original request hasn’t completed, the client sends another request, ideally to a different replica.</p><p>The first successful response wins.</p><pre>Time<br> |<br> |  Primary request<br> |  ────────────────────────────────&gt;<br> |<br> |  Wait: 100ms<br> |<br> |  Hedge request<br> |  ─────────────────────&gt;<br> |<br> |  Hedge response: 160ms<br> |  Primary response: 450ms<br> |<br> |  Return hedge response<br> |  Cancel primary request<br> |</pre><p>The important detail is that the second request starts before the first one fails.</p><p>This is fundamentally different from a traditional retry.</p><p>A retry waits for an unsuccessful attempt before trying again.</p><p>A hedge assumes that an unusually slow request might be an outlier and starts another attempt proactively.</p><h3>Why does this work?</h3><p>Imagine two replicas:</p><ul><li>Replica A is experiencing temporary contention.</li><li>Replica B is healthy.</li></ul><p>The primary request goes to A.</p><p>After 100ms, it still hasn’t completed, so the client sends a hedge to B.</p><p>If B responds in 60ms, the client returns that response instead of waiting for A.</p><p>The total latency becomes approximately 160ms instead of 450ms.</p><p>The original request might still finish later, but its result is no longer needed.</p><h3>3. Hedging vs. Retrying</h3><p>These mechanisms are related, but they solve slightly different problems.</p><p>MechanismWhen the next attempt startsPrimary goalRetryAfter an attempt failsRecover from transient failuresHedgingWhile an attempt is still runningReduce tail latencyTimeoutAfter a deadline expiresBound waiting timeCircuit breakerWhen a dependency is unhealthyStop sending harmful traffic</p><p>A retry responds to failure.</p><p>A hedge responds to excessive waiting.</p><p>This distinction matters because hedging can increase downstream load even when the original request would eventually succeed.</p><p>For that reason, hedging should generally be limited to safe, idempotent operations, especially reads.</p><h3>4. Choosing the Hedging Delay</h3><p>The hedging delay is one of the most important configuration decisions.</p><p>If the delay is too short, the client duplicates too many requests.</p><p>If it is too long, the hedge provides little latency improvement.</p><p>A reasonable starting point is to use a measured latency percentile for a particular operation.</p><p>For example:</p><p>PercentileLatencyp5040msp9070msp95100msp99450ms</p><p>A 100ms hedging delay means that requests exceeding the p95 threshold become candidates for hedging.</p><p>Under stable conditions, approximately 5% of requests may reach that threshold.</p><p>This does not mean exactly 5% of requests will generate a hedge: requests can complete at the threshold boundary, and actual behavior depends on the latency distribution and scheduling.</p><p>The important principle is to trigger hedging in the tail, not in the middle of normal traffic.</p><p>The original <em>Tail at Scale</em> paper discusses using a high-percentile latency threshold to limit duplicate traffic while reducing extreme latency. Its examples demonstrate why carefully selected hedging delays can achieve substantial tail-latency improvements with relatively modest additional load.</p><p>Source: <a href="https://www.andrew.cmu.edu/user/gaurij/18-847F-Readings/tail_at_scale.pdf">https://www.andrew.cmu.edu/user/gaurij/18-847F-Readings/tail_at_scale.pdf</a></p><h3>5. Implementing Hedged Requests in Go</h3><p>Let’s build a small hedging executor.</p><p>Our implementation will support:</p><ul><li>An immediate primary request</li><li>A configurable hedging delay</li><li>A maximum number of attempts</li><li>Context cancellation</li><li>First-successful-response semantics</li><li>Retryable error classification</li><li>An optional shared retry budget</li></ul><p>We’ll use Go generics so the executor can return any response type.</p><h3>Step 1: Define the policy</h3><pre>package hedge<br><br>import (<br>    &quot;context&quot;<br>    &quot;errors&quot;<br>    &quot;fmt&quot;<br>    &quot;time&quot;<br>)<br><br>var (<br>    ErrInvalidPolicy = errors.New(&quot;invalid hedging policy&quot;)<br>    ErrBudgetDenied  = errors.New(&quot;hedging budget exhausted&quot;)<br>)<br><br>type Budget interface {<br>    TryTake() bool<br>}<br><br>type Policy struct {<br>    Delay       time.Duration<br>    MaxAttempts int<br>    Budget      Budget<br>    ShouldHedge func(error) bool<br>}<br><br>type result[T any] struct {<br>    value T<br>    err   error<br>}</pre><p>The Budget interface allows us to reuse the retry budget implementation from the previous article.</p><p>The original request does not consume a hedge token.</p><p>Every additional request does.</p><p>We also use ShouldHedge to distinguish errors that should terminate the operation from errors that may allow another attempt.</p><p>For example, a validation error should not trigger another request.</p><h3>Step 2: Implement the executor</h3><pre>func Do[T any](<br>    ctx context.Context,<br>    policy Policy,<br>    operation func(context.Context, int) (T, error),<br>) (T, error) {<br>    var zero T<br><br>    if policy.Delay &lt;= 0 ||<br>        policy.MaxAttempts &lt; 1 ||<br>        policy.MaxAttempts &gt; 3 ||<br>        operation == nil ||<br>        policy.ShouldHedge == nil {<br>        return zero, ErrInvalidPolicy<br>    }<br><br>    if err := ctx.Err(); err != nil {<br>        return zero, err<br>    }<br><br>    hedgeCtx, cancel := context.WithCancel(ctx)<br><br>    defer cancel()<br><br>    results := make(chan result[T], policy.MaxAttempts)<br>    launched := 0<br>    inFlight := 0<br><br>    var lastErr error<br><br>    startAttempt := func() {<br>        launched++<br>        inFlight++<br>        attempt := launched<br>        go func() {<br>            value, err := operation(hedgeCtx, attempt)<br>            results &lt;- result[T]{<br>                value: value,<br>                err:   err,<br>            }<br>        }()<br>    }<br><br>    // The primary request starts immediately.<br>    startAttempt()<br><br>    var timer *time.Timer<br><br>    var timerC &lt;-chan time.Time<br><br>    if policy.MaxAttempts &gt; 1 {<br>        timer = time.NewTimer(policy.Delay)<br>        timerC = timer.C<br>        defer timer.Stop()<br>    }<br><br>    for {<br>        select {<br>        case &lt;-ctx.Done():<br>            return zero, ctx.Err()<br>        case res := &lt;-results:<br>            inFlight--<br>            if res.err == nil {<br>                // First successful response wins.<br>                return res.value, nil<br>            }<br><br>            lastErr = res.err<br><br>            if !policy.ShouldHedge(res.err) {<br>                return zero, res.err<br>            }<br><br>            if inFlight == 0 &amp;&amp;<br>                launched == policy.MaxAttempts {<br>                return zero, lastErr<br>            }<br>        case &lt;-timerC:<br>            if launched &gt;= policy.MaxAttempts {<br>                timerC = nil<br>                continue<br>            }<br><br>            if policy.Budget != nil &amp;&amp;<br>                !policy.Budget.TryTake() {<br>                // No more additional requests.<br>                timerC = nil<br>                if inFlight == 0 {<br>                    if lastErr != nil {<br>                        return zero, lastErr<br>                    }<br>                    return zero, ErrBudgetDenied<br>                }<br>                continue<br>            }<br><br>            startAttempt()<br><br>            if launched &lt; policy.MaxAttempts {<br>                timer.Reset(policy.Delay)<br>            } else {<br>                timerC = nil<br>            }<br>        }<br>    }<br>}</pre><p>Let’s examine the important decisions.</p><p><strong>The primary request starts immediately.</strong></p><p>We don’t wait for a timer before sending the first request.</p><p><strong>Additional requests start only after the configured delay.</strong></p><p>If the primary finishes successfully before the delay expires, the executor returns without sending a hedge.</p><p><strong>The first successful response wins.</strong></p><p>As soon as a successful result arrives, the function returns and cancels the shared context.</p><p><strong>Errors are classified.</strong></p><p>A fatal error can terminate the operation immediately. A retryable error can be treated as non-final while another attempt is still running.</p><p><strong>The number of attempts is bounded.</strong></p><p>The implementation intentionally limits the example to three total attempts, including the original request.</p><p>This is a conservative default for the example, not a universal limit.</p><h3>Step 3: Use the executor</h3><p>Imagine a service that reads market data from multiple replicas.</p><pre>type Price struct {<br>    Symbol string<br>    Value  float64<br>}<br><br>func fetchPrice(<br>    ctx context.Context,<br>    attempt int,<br>) (Price, error) {<br>    replica := selectReplica(attempt)<br>    return replica.Fetch(ctx, &quot;AAPL&quot;)<br>}</pre><p>We can now use the hedging executor:</p><pre>policy := hedge.Policy{<br>    Delay:       100 * time.Millisecond,<br>    MaxAttempts: 2,<br>    Budget:      retryBudget,<br>    ShouldHedge: isRetryableError,<br>}<br><br>price, err := hedge.Do(<br>    ctx,<br>    policy,<br>    fetchPrice,<br>)<br><br>if err != nil {<br>    return err<br>}<br><br>fmt.Printf(<br>    &quot;%s: %.2f\n&quot;,<br>    price.Symbol,<br>    price.Value,<br>)</pre><p>The primary request goes to the first replica.</p><p>If it hasn’t completed after 100ms, the executor checks the budget and starts another request.</p><p>If the second replica responds first, the result is returned and the first request is canceled.</p><p>The example assumes that selectReplica routes attempts to distinct, suitable replicas. Sending both copies to the same overloaded instance may provide little benefit.</p><h3>6. Cancellation Is Not the Same as Stopping Work</h3><p>This is one of the most important details in a hedging implementation.</p><p>When the winning request completes, the executor calls cancel() on the shared context.</p><p>The losing requests receive cancellation through their contexts.</p><p>But cancellation is cooperative.</p><p>It does not forcibly terminate a goroutine, an HTTP request, a database query, or server-side processing.</p><p>For example, a dependency may continue executing a query even after the client disconnects.</p><p>A correct operation should propagate its context:</p><pre>func fetch(<br>    ctx context.Context,<br>    client *http.Client,<br>) error {<br>    req, err := http.NewRequestWithContext(<br>        ctx,<br>        http.MethodGet,<br>        &quot;https://api.example.com/prices/AAPL&quot;,<br>        nil,<br>    )<br>    if err != nil {<br>        return err<br>    }<br><br>    resp, err := client.Do(req)<br><br>    if err != nil {<br>        return err<br>    }<br><br>    defer resp.Body.Close()<br><br>    return nil<br>}</pre><p>The server should also respect request cancellation where possible.</p><p>Otherwise, the client may stop waiting while the dependency continues doing unnecessary work.</p><p>This is especially important for expensive database queries and CPU-intensive operations.</p><h3>7. Why a Hedge Budget Matters</h3><p>Hedging creates additional traffic.</p><p>Imagine a service receiving 10,000 requests per second.</p><p>If 5% reach the hedging threshold, that could mean approximately 500 additional attempts per second.</p><p>Now imagine the dependency is already degraded.</p><p>More concurrent requests may increase queueing, which increases latency, which triggers more hedges.</p><p>The mechanism can create the same kind of amplification problem that we discussed in the Retry Budgets article.</p><p>This is why the hedge budget should be shared across requests.</p><p>A simple rule:</p><ul><li>The original request is always allowed, subject to normal admission controls.</li><li>Each additional hedge requires a token.</li><li>Successful normal traffic replenishes the budget.</li><li>If the budget is exhausted, no new hedges are launched.</li></ul><p>For example:</p><pre>budget := retry.NewRetryBudget(<br>    20,<br>    0.1,<br>)</pre><p>The exact budget should be tuned according to the dependency’s spare capacity and the service’s traffic patterns.</p><p>The important part is that hedging is not an unlimited permission to duplicate requests.</p><p>It is an additional workload that must be controlled.</p><h3>8. Hedging and Adaptive Concurrency</h3><p>Hedging and adaptive concurrency work particularly well together, but they need to share a consistent view of downstream capacity.</p><p>Adaptive concurrency adjusts how many requests are allowed in flight based on observed dependency behavior.</p><p>Hedging creates additional in-flight requests.</p><p>If the adaptive limiter counts only original requests, hedged requests can bypass the intended concurrency ceiling.</p><p>That defeats the purpose of the limiter.</p><p>A safer integration is:</p><ol><li>The primary request acquires a concurrency permit.</li><li>A hedge must acquire its own permit.</li><li>If no permit is available, the hedge is skipped.</li><li>The winning request returns its result.</li><li>Every attempt releases its own permit when it actually completes.</li></ol><p>There is an important implementation detail here.</p><p>Do not release the losing request’s concurrency permit merely because the caller stopped waiting for it.</p><p>The permit must remain held until the underlying operation actually completes or otherwise confirms that the protected work has stopped.</p><p>Otherwise, the limiter undercounts active downstream work.</p><p>This is also why a shared cancellation context alone is not enough to guarantee that hedged work has disappeared.</p><h3>9. Hedging Requires Idempotency</h3><p>Sending the same operation more than once is not always safe.</p><p>Consider these operations:</p><p>OperationHedging suitabilityRead a market priceGenerally suitableRead a portfolioGenerally suitableFetch cached configurationGenerally suitableCreate a paymentUnsafe without idempotencySubmit an orderUnsafe without deduplicationSend an emailMay produce duplicatesUpdate an account balanceUnsafe without strong safeguards</p><p>A hedge can reach two different servers before either has completed.</p><p>Both servers may execute the operation.</p><p>Canceling one request does not guarantee that its side effects are rolled back.</p><p>For operations with side effects, use an idempotency key or another server-side deduplication mechanism.</p><p>Even then, the server must correctly enforce the idempotency contract.</p><p>Hedging should generally be introduced first for read-only operations where duplicate execution is safe.</p><h3>10. Where Should Hedged Requests Be Sent?</h3><p>Sending a duplicate request to the same server is often ineffective.</p><p>If that server is experiencing CPU contention, a long queue, or an overloaded database connection pool, the second request may suffer from the same problem.</p><p>Ideally, the hedge should target an independent replica.</p><p>But independence is not guaranteed.</p><p>Two replicas may share:</p><ul><li>The same database</li><li>The same network path</li><li>The same cache</li><li>The same storage system</li><li>The same overloaded region</li></ul><p>This is called correlated failure or correlated latency.</p><p>If both requests depend on the same struggling resource, hedging may increase load without meaningfully reducing latency.</p><p>Replica selection should consider failure domains and actual dependency topology.</p><h3>11. Observability: Measure the Cost of Hedging</h3><p>A hedging system needs more than a request-latency dashboard.</p><p>You need to understand whether the reduction in tail latency justifies the additional work.</p><p>Useful metrics include:</p><p>MetricTypePurposehedged_requests_totalCounterNumber of additional attemptshedge_wins_totalCounterRequests where a hedge returned firsthedge_budget_denied_totalCounterHedges rejected by the budgethedge_canceled_totalCounterLosing attempts canceled by the clienthedge_attempts_totalCounterTotal attempts, including originalshedge_delay_secondsHistogramTime before a hedge is launchedrequest_latency_secondsHistogramEnd-to-end request latency</p><p>A useful derived metric is the hedge win rate:</p><p>[ HedgeWinRate = \frac{HedgeWins}{HedgedRequests} ]</p><p>If hedges rarely win, you may be generating additional traffic without much benefit.</p><p>Also compare:</p><ul><li>p50 latency</li><li>p95 latency</li><li>p99 latency</li><li>Downstream request volume</li><li>Dependency CPU and memory usage</li><li>Cancellation effectiveness</li><li>Error rate</li></ul><p>The goal is not to minimize p99 at any cost.</p><p>It is to improve tail latency while keeping the additional workload within the dependency’s capacity.</p><h3>12. Testing Hedged Requests</h3><p>Concurrency behavior needs deterministic tests.</p><p>At minimum, test these scenarios:</p><ul><li>The primary request succeeds before the hedging delay.</li><li>The primary request is slow and the hedge wins.</li><li>The primary request fails with a retryable error.</li><li>A fatal error cancels outstanding attempts.</li><li>The hedge budget prevents additional requests.</li><li>The parent context is canceled.</li><li>The maximum number of attempts is respected.</li><li>Losing operations receive cancellation.</li><li>A losing operation that ignores cancellation does not release its concurrency permit prematurely.</li></ul><p>A simple test for the winning hedge could use controlled channels instead of arbitrary sleeps:</p><pre>func TestHedgeWins(t *testing.T) {<br>    primaryStarted := make(chan struct{})<br>    hedgeStarted := make(chan struct{})<br><br>    ctx, cancel := context.WithTimeout(<br>        context.Background(),<br>        time.Second,<br>    )<br><br>    defer cancel()<br><br>    policy := Policy{<br>        Delay:       20 * time.Millisecond,<br>        MaxAttempts: 2,<br>        ShouldHedge: func(error) bool {<br>            return true<br>        },<br>    }<br><br>    result, err := Do(<br>        ctx,<br>        policy,<br>        func(<br>            ctx context.Context,<br>            attempt int,<br>        ) (string, error) {<br>            if attempt == 1 {<br>                close(primaryStarted)<br>                &lt;-ctx.Done()<br>                return &quot;&quot;, ctx.Err()<br>            }<br>            close(hedgeStarted)<br>            return &quot;hedge response&quot;, nil<br>        },<br>    )<br><br>    if err != nil {<br>        t.Fatal(err)<br>    }<br><br>    if result != &quot;hedge response&quot; {<br>        t.Fatalf(<br>            &quot;unexpected result: %s&quot;,<br>            result,<br>        )<br>    }<br><br>    &lt;-primaryStarted<br>    &lt;-hedgeStarted<br>}</pre><p>The test verifies that the second request can win and that the operation completes successfully.</p><p>In a complete test suite, also verify that the primary operation observes cancellation.</p><p>Run the race detector:</p><pre>go test -race ./...</pre><p>Finally, use load tests with a dependency that deliberately introduces variable latency.</p><p>A useful experiment compares:</p><ol><li>Hedging disabled.</li><li>Hedging enabled with a conservative delay.</li><li>Hedging enabled with an aggressive delay.</li><li>Hedging enabled with a shared budget.</li><li>Hedging enabled with adaptive concurrency.</li></ol><p>Compare both latency improvements and additional downstream traffic.</p><h3>13. Production Checklist</h3><p>Before introducing hedged requests into a production service:</p><ul><li>[ ] Hedge only safe, idempotent operations.</li><li>[ ] Select a delay from measured latency distributions.</li><li>[ ] Use a strict maximum attempt count.</li><li>[ ] Require a shared budget for additional attempts.</li><li>[ ] Ensure hedges target independent replicas where possible.</li><li>[ ] Apply concurrency limits to every attempt.</li><li>[ ] Propagate context cancellation.</li><li>[ ] Ensure permits remain held while work is still active.</li><li>[ ] Respect the original request deadline.</li><li>[ ] Measure hedge win rate and additional traffic.</li><li>[ ] Test correlated latency and dependency overload.</li><li>[ ] Roll out gradually and compare against a no-hedging baseline.</li></ul><h3>Conclusion</h3><p>Tail latency is one of the most difficult problems in distributed systems because a small number of unusually slow operations can dominate the response time of an entire request.</p><p>Hedged requests provide a way to reduce that impact.</p><p>By launching a second request after a carefully selected delay, a client can avoid waiting for an unusually slow replica and use the first successful response.</p><p>But hedging is not free.</p><p>Every additional request consumes capacity, and uncontrolled duplication can make an already degraded dependency even slower.</p><p>The right approach combines measured hedging delays, idempotent operations, cancellation, retry budgets, and adaptive concurrency limits.</p><p><strong>The goal isn’t to send more requests. It’s to get the response sooner without making the system slower for everyone else.</strong></p><h3>References</h3><ul><li><a href="https://research.google/pubs/the-tail-at-scale/">The Tail at Scale — Google Research</a></li><li><a href="https://grpc.io/docs/guides/request-hedging/">Request Hedging — gRPC Documentation</a></li><li><a href="https://github.com/grpc/proposal/blob/master/A6-client-retries.md">gRPC Retry and Hedging Design</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8ce18e1c1a8e" width="1" height="1" alt=""><hr><p><a href="https://alt.ctrlmag.com/hedged-requests-in-go-reducing-tail-latency-without-overloading-dependencies-8ce18e1c1a8e">Hedged Requests in Go: Reducing Tail Latency Without Overloading Dependencies</a> was originally published in <a href="https://alt.ctrlmag.com">Alt.</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AG-UI & A2UI: Building Agent-Powered Interfaces Beyond Chat]]></title>
            <link>https://medium.com/@serifcolakel/ag-ui-a2ui-building-agent-powered-interfaces-beyond-chat-5bd28088acda?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/5bd28088acda</guid>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 14:13:21 GMT</pubDate>
            <atom:updated>2026-11-01T14:13:21.264Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BJt7K-x39C8_of9PPV1Evg.png" /><figcaption>AG-UI &amp; A2UI: Building Agent-Powered Interfaces Beyond Chat Image</figcaption></figure><p><em>How event-driven agent interactions and generative UI are changing the way we build AI-powered frontend applications.</em></p><h3>1. Why Are Chat Interfaces No Longer Enough?</h3><p>Most AI applications start with a familiar interface: a text input, a message history, and a streaming response.</p><p>This works well for question answering, summarization, and conversational experiences. However, agent-based applications introduce a different set of interaction requirements.</p><p>Consider an AI assistant that analyzes a user’s investment portfolio, investigates market movements, or prepares a technical report.</p><p>A conventional chat interface might return a response containing:</p><ul><li>A summary of the analysis</li><li>A list of recommendations</li><li>Several data points</li><li>A suggested next action</li></ul><p>But what if the user needs more than a textual response?</p><p>What if the application needs to display:</p><ul><li>Interactive charts</li><li>Dynamic forms</li><li>Data tables</li><li>Approval workflows</li><li>Progress indicators</li><li>Context-aware actions</li><li>Multi-step interfaces that evolve as the agent works</li></ul><p>We could build every possible interface in advance and ask the model to select the appropriate components. This approach works for predictable workflows, but it becomes restrictive when the interaction itself is dynamic.</p><p>Another option is to allow the model to generate HTML, React components, or JavaScript directly. However, executing arbitrary model-generated code introduces significant security, maintainability, and governance challenges.</p><p>This is where two interesting technologies enter the picture:</p><p><strong>AG-UI (Agent-User Interaction Protocol)</strong> and <strong>A2UI (Agent-to-User Interface)</strong>.</p><p>Although their names are similar, they address different problems.</p><p>AG-UI standardizes how agents communicate with user-facing applications. A2UI provides a declarative format for describing interfaces that client applications can safely render using their own component libraries.</p><p>Together, they provide an interesting foundation for building agent-powered applications beyond traditional chat interfaces.</p><p><strong>References:</strong> <a href="https://docs.ag-ui.com/">AG-UI Documentation</a> · <a href="https://a2ui.org/">Google A2UI</a></p><h3>2. What Is AG-UI?</h3><p>AG-UI stands for Agent-User Interaction Protocol.</p><p>It is an open, lightweight, event-based protocol designed to standardize communication between AI agents and frontend applications.</p><p>The underlying problem is straightforward.</p><p>An agent does not always produce one response and finish. It may execute multiple tools, delegate work, update its state, request additional input, and continue processing.</p><p>A traditional request-response model does not necessarily represent these interactions well.</p><p>For example, imagine an agent performing a database analysis:</p><ol><li>The user requests an analysis.</li><li>The agent starts executing the task.</li><li>It calls a database tool.</li><li>The query completes.</li><li>The agent processes the returned data.</li><li>It generates a chart.</li><li>It asks the user to confirm an additional filter.</li><li>It completes the analysis.</li></ol><p>If the frontend only receives the final response, the user has no visibility into what happened during execution.</p><p>We could implement custom WebSocket messages, HTTP streaming events, and application-specific state synchronization. But every application would need to define and maintain its own communication contract.</p><p><strong>AG-UI provides a standardized event model for this interaction.</strong></p><p>It allows agent backends to communicate execution events, messages, tool calls, state changes, and activity updates to the frontend.</p><p>The protocol is intentionally not tied to one agent framework or frontend technology.</p><p>You can build the backend using LangGraph, Google ADK, CrewAI, or a custom runtime, while the frontend uses React or another supported client implementation.</p><p>The important point is that AG-UI does not define how an agent reasons or executes its internal logic. It defines how that execution is communicated to the user-facing application.</p><p><strong>Reference:</strong> <a href="https://docs.ag-ui.com/">AG-UI — Introduction</a></p><h3>3. How Does AG-UI Work?</h3><p>AG-UI follows an event-driven architecture.</p><p>Instead of treating an agent interaction as a single response, the backend emits a sequence of typed events that the frontend consumes.</p><p>A simplified architecture looks like this:</p><pre>                  User<br>                   |<br>                   v<br>             React Application<br>                   |<br>                   v<br>               AG-UI Client<br>                   |<br>            Event Stream / SSE<br>                   |<br>                   v<br>              Agent Runtime<br>                   |<br>          +--------+--------+<br>          |        |        |<br>          v        v        v<br>        LLM      Tools    Subagents<br>          |        |        |<br>          +--------+--------+<br>                   |<br>                   v<br>             Agent Events<br>                   |<br>                   v<br>              AG-UI Client<br>                   |<br>                   v<br>              Update UI</pre><p>The frontend and backend have separate responsibilities.</p><p><strong>Backend responsibilities:</strong></p><ul><li>Execute agent logic.</li><li>Handle model interactions.</li><li>Execute tools.</li><li>Maintain execution state.</li><li>Emit standardized events.</li><li>Handle additional user input and resume interactions.</li></ul><p><strong>Frontend responsibilities:</strong></p><ul><li>Initiate agent runs.</li><li>Consume event streams.</li><li>Render messages and activity updates.</li><li>Synchronize application state.</li><li>Present tool activity.</li><li>Handle user interactions and approvals.</li></ul><p>AG-UI is transport-agnostic. Common integration patterns include Server-Sent Events (SSE), WebSockets, and webhooks.</p><p>The official HttpAgent client supports HTTP SSE and an HTTP binary transport.</p><p>This separation means we do not have to make frontend components understand the implementation details of the backend agent framework.</p><p><strong>Reference:</strong> <a href="https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/concepts/architecture.mdx">AG-UI — Core Architecture</a></p><h3>4. Understanding AG-UI Events</h3><p>Events are the foundation of AG-UI.</p><p>Rather than sending an unstructured text response, an agent can communicate different types of events depending on what is happening during execution.</p><p>The main event categories include:</p><h3>4.1. Lifecycle Events</h3><p>These events describe the execution lifecycle.</p><p>EventPurposeRUN_STARTEDAn agent run has startedRUN_FINISHEDAn agent run has finishedRUN_ERRORAn execution error occurredSTEP_STARTEDA new execution step startedSTEP_FINISHEDAn execution step finished</p><p>This allows the frontend to display meaningful execution states instead of relying on a generic loading indicator.</p><h3>4.2. Text Message Events</h3><p>These events represent streaming messages.</p><p>EventPurposeTEXT_MESSAGE_STARTStarts a messageTEXT_MESSAGE_CONTENTSends a text chunkTEXT_MESSAGE_ENDCompletes a message</p><p>The frontend can append each text chunk as it arrives.</p><p>This provides a familiar streaming experience while keeping the underlying event structure explicit.</p><h3>4.3. Tool Call Events</h3><p>Tool execution is an important part of agent workflows.</p><p>AG-UI defines events such as:</p><ul><li>TOOL_CALL_START</li><li>TOOL_CALL_ARGS</li><li>TOOL_CALL_END</li><li>TOOL_CALL_RESULT</li></ul><p>These events allow the frontend to display which tool is being used and follow its execution lifecycle.</p><p>For example, an interface might display:</p><pre>Analyzing repository...<br>    |<br>    v<br>GitLab: Fetching merge request<br>    |<br>    v<br>Code Review: Analyzing changes<br>    |<br>    v<br>Security Check: Completed<br>    |<br>    v<br>Generating report</pre><p>One distinction is important: receiving a tool-call event does not automatically mean the frontend should execute that tool. Tool execution and authorization remain responsibilities of the appropriate runtime or application layer.</p><h3>4.4. State Management Events</h3><p>AG-UI also defines events for synchronizing application and agent state.</p><p>EventPurposeSTATE_SNAPSHOTSends a complete state snapshotSTATE_DELTASends incremental state changesMESSAGES_SNAPSHOTSynchronizes conversation history</p><p>For example, an agent might update the current analysis status:</p><pre>{<br>  &quot;type&quot;: &quot;STATE_SNAPSHOT&quot;,<br>  &quot;snapshot&quot;: {<br>    &quot;status&quot;: &quot;analyzing&quot;,<br>    &quot;completedSteps&quot;: 3,<br>    &quot;totalSteps&quot;: 5,<br>    &quot;currentTask&quot;: &quot;Evaluating transaction data&quot;<br>  }<br>}</pre><p>The frontend can consume this state and update progress indicators, dashboards, or other components.</p><h3>4.5. Activity and Subagent Events</h3><p>Modern agent systems often involve multiple agents working together.</p><p>AG-UI includes activity and subagent-related events, allowing a frontend to represent more complex execution patterns.</p><p>For example:</p><ul><li>A research agent starts working.</li><li>A data analysis agent receives a delegated task.</li><li>The security agent completes its review.</li><li>The coordinator produces the final result.</li></ul><p>This makes it possible to build interfaces that expose useful execution activity without requiring users to understand the internal implementation of each agent.</p><p>The current AG-UI v1.0 schema documents event families for lifecycle, text, tools, state, activity, subagents, reasoning, and custom events.</p><p>A critical implementation consideration: reasoning-related events should not be treated as permission to expose raw model chain-of-thought. User-facing applications should expose approved progress summaries and operational status instead.</p><p><strong>Reference:</strong> <a href="https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/concepts/events.mdx">AG-UI — Event Types and Specification</a></p><h3>5. How to Integrate AG-UI with a React Application</h3><p>AG-UI provides a TypeScript SDK that can be used to connect frontend applications to compatible agent endpoints.</p><p>The official package is @ag-ui/client.</p><h3>Step 1: Install the package</h3><pre>pnpm add @ag-ui/client @ag-ui/core</pre><h3>Step 2: Create an HTTP agent</h3><p>The HttpAgent class provides a client for communicating with an AG-UI-compatible backend.</p><pre>import { HttpAgent } from &#39;@ag-ui/client&#39;;<br><br>const agent = new HttpAgent({<br>  url: &#39;/api/agent&#39;,<br>  agentId: &#39;portfolio-assistant&#39;,<br>  threadId: crypto.randomUUID(),<br>});</pre><p>The configuration defines the backend endpoint and identifies the agent and conversation thread.</p><h3>Step 3: Send a message and consume events</h3><p>We can use the agent’s addMessage and runAgent methods to start an interaction.</p><pre>import { HttpAgent } from &#39;@ag-ui/client&#39;;<br>import { EventType } from &#39;@ag-ui/core&#39;;<br><br>const agent = new HttpAgent({<br>  url: &#39;/api/agent&#39;,<br>  agentId: &#39;portfolio-assistant&#39;,<br>  threadId: crypto.randomUUID(),<br>});<br><br>agent.addMessage({<br>  id: crypto.randomUUID(),<br>  role: &#39;user&#39;,<br>  content: &#39;Analyze my portfolio and summarize its performance.&#39;,<br>});<br><br>await agent.runAgent(<br>  {<br>    tools: [],<br>    context: [],<br>  },<br>  {<br>    onEvent: ({ event }) =&gt; {<br>      switch (event.type) {<br>        case EventType.RUN_STARTED:<br>          console.log(&#39;Agent execution started&#39;);<br>          break;<br>        case EventType.TEXT_MESSAGE_CONTENT:<br>          console.log(event.delta);<br>          break;<br>        case EventType.TOOL_CALL_START:<br>          console.log(&#39;Tool started:&#39;, event.toolCallName);<br>          break;<br>        case EventType.RUN_FINISHED:<br>          console.log(&#39;Agent execution completed&#39;);<br>          break;<br>        case EventType.RUN_ERROR:<br>          console.error(&#39;Agent execution failed&#39;);<br>          break;<br>      }<br>    },<br>  },<br>);</pre><p>The example demonstrates the client-side event handling pattern. In a complete application, event handlers would update React state, render tool activity, and maintain conversation state.</p><p>The agent SDK also supports shared state, event subscriptions, middleware, and cancellation through abortRun().</p><h3>Step 4: Synchronize agent state with React</h3><p>One of the most useful capabilities is the ability to keep frontend and agent state synchronized.</p><p>For example, an agent can update task progress while the frontend renders the current status.</p><p>With a framework integration such as CopilotKit, this can be implemented through hooks such as useAgent.</p><p>Conceptually:</p><pre>import { useAgent } from &#39;@copilotkit/react-core/v2&#39;;<br><br>function TaskProgress() {<br>  const { agent } = useAgent();<br><br>  const progress = agent.state.progress ?? {<br>    completed: 0,<br>    total: 0,<br>  };<br><br>  return (<br>    &lt;section&gt;<br>      &lt;h3&gt;Agent Progress&lt;/h3&gt;<br>      &lt;p&gt;<br>        {progress.completed} / {progress.total} completed<br>      &lt;/p&gt;<br>    &lt;/section&gt;<br>  );<br>}</pre><p>The component reacts to changes in the agent’s shared state.</p><p>The important architectural detail is that AG-UI defines the communication contract, while libraries such as CopilotKit provide additional frontend integration and application-level abstractions.</p><p>We are not required to use CopilotKit to implement AG-UI. A custom frontend can consume the protocol directly.</p><p><strong>References:</strong> <a href="https://github.com/ag-ui-protocol/ag-ui">AG-UI TypeScript SDK</a> · <a href="https://docs.copilotkit.ai/shared-state">CopilotKit Shared State</a></p><h3>6. What Is A2UI?</h3><p>AG-UI solves the communication problem, but there is another question:</p><p><strong>What if an agent needs to generate an actual user interface instead of returning text?</strong></p><p>A2UI stands for Agent-to-User Interface.</p><p>It is an open-source project initiated by Google that provides a declarative protocol for generating and updating user interfaces from structured JSON messages.</p><p>The central idea is simple.</p><p>Instead of asking an LLM to generate HTML, JavaScript, or React code, we let it describe what the interface should contain.</p><p>For example, an agent may want to display:</p><ul><li>A title</li><li>A summary card</li><li>A table</li><li>A date picker</li><li>A confirmation button</li></ul><p>It can describe these elements using A2UI messages.</p><p>The frontend then maps those descriptions to its own approved components.</p><p>This creates a separation between UI generation and UI execution.</p><p>The agent describes the interface. The application decides how that interface is rendered.</p><h3>Why is this important?</h3><p>Imagine allowing an LLM to generate the following:</p><pre>function Dashboard() {<br>  // Arbitrary model-generated code<br>}</pre><p>Executing model-generated React code directly would create substantial security and maintenance problems.</p><p>Instead, we can let the model return structured data:</p><pre>{<br>  &quot;component&quot;: &quot;Card&quot;,<br>  &quot;children&quot;: [<br>    {<br>      &quot;component&quot;: &quot;Text&quot;,<br>      &quot;text&quot;: &quot;Portfolio Summary&quot;<br>    }<br>  ]<br>}</pre><p>The frontend maps Card and Text to components that already exist in its design system.</p><p>The model never needs to execute arbitrary JavaScript to create the interface.</p><p>This is the main architectural idea behind A2UI.</p><p><strong>Reference:</strong> <a href="https://a2ui.org/">Google A2UI — Official Documentation</a></p><h3>7. How Does A2UI Work?</h3><p>A2UI uses a JSON-based streaming protocol.</p><p>The agent sends messages to the frontend, and the frontend processes those messages to create or update UI surfaces.</p><p>A2UI v0.9.1 defines four primary message types.</p><h3>7.1. createSurface</h3><p>Creates a new UI surface.</p><p>A surface represents a UI region that contains components and associated data.</p><pre>{<br>  &quot;version&quot;: &quot;v0.9.1&quot;,<br>  &quot;createSurface&quot;: {<br>    &quot;surfaceId&quot;: &quot;portfolio-dashboard&quot;,<br>    &quot;catalogId&quot;: &quot;https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json&quot;<br>  }<br>}</pre><h3>7.2. updateComponents</h3><p>Defines or updates the components within a surface.</p><p>The components are represented as a flat list with references that define their relationships.</p><p>For example:</p><pre>{<br>  &quot;version&quot;: &quot;v0.9.1&quot;,<br>  &quot;updateComponents&quot;: {<br>    &quot;surfaceId&quot;: &quot;portfolio-dashboard&quot;,<br>    &quot;components&quot;: [<br>      {<br>        &quot;id&quot;: &quot;root&quot;,<br>        &quot;component&quot;: &quot;Column&quot;,<br>        &quot;children&quot;: [<br>          &quot;title&quot;,<br>          &quot;summary&quot;<br>        ]<br>      },<br>      {<br>        &quot;id&quot;: &quot;title&quot;,<br>        &quot;component&quot;: &quot;Text&quot;,<br>        &quot;text&quot;: &quot;Portfolio Overview&quot;,<br>        &quot;variant&quot;: &quot;h1&quot;<br>      },<br>      {<br>        &quot;id&quot;: &quot;summary&quot;,<br>        &quot;component&quot;: &quot;Text&quot;,<br>        &quot;text&quot;: &quot;Your portfolio analysis is ready.&quot;<br>      }<br>    ]<br>  }<br>}</pre><p>The renderer understands the component definitions and constructs the corresponding UI tree.</p><h3>7.3. updateDataModel</h3><p>Updates the data displayed by the components.</p><p>A2UI separates component structure from application data.</p><p>This is particularly useful because changing data does not necessarily require sending the entire component tree again.</p><p>For example:</p><pre>{<br>  &quot;version&quot;: &quot;v0.9.1&quot;,<br>  &quot;updateDataModel&quot;: {<br>    &quot;surfaceId&quot;: &quot;portfolio-dashboard&quot;,<br>    &quot;path&quot;: &quot;/portfolio&quot;,<br>    &quot;value&quot;: {<br>      &quot;totalValue&quot;: 125000,<br>      &quot;currency&quot;: &quot;USD&quot;,<br>      &quot;change&quot;: 4.2<br>    }<br>  }<br>}</pre><p>Components can reference values in the data model using JSON Pointer paths.</p><p>When the data model changes, the renderer can update the relevant parts of the interface.</p><h3>7.4. deleteSurface</h3><p>Removes a previously created surface and its associated components and data.</p><pre>{<br>  &quot;version&quot;: &quot;v0.9.1&quot;,<br>  &quot;deleteSurface&quot;: {<br>    &quot;surfaceId&quot;: &quot;portfolio-dashboard&quot;<br>  }<br>}</pre><p>This is useful for managing dynamic UI regions as an interaction evolves.</p><h3>The complete flow</h3><pre>User Request<br>     |<br>     v<br>Agent Runtime<br>     |<br>     v<br>LLM Generates A2UI Messages<br>     |<br>     v<br>    JSON Stream<br>     |<br>     v<br>A2UI Message Processor<br>     |<br>     v<br>Component Catalog<br>     |<br>     v<br>React / Flutter / Angular Renderer<br>     |<br>     v<br>Interactive User Interface</pre><p>The process can happen incrementally.</p><p>The agent can create a surface first, add components later, and update the data model as more information becomes available.</p><p>This is different from generating an entire interface in one response.</p><p><strong>References:</strong> <a href="https://github.com/a2ui-project/a2ui/blob/main/specification/v0_9_1/docs/a2ui_protocol.md">A2UI Protocol v0.9.1</a> · <a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/quickstart.md">A2UI Quickstart</a></p><h3>8. Integrating A2UI with React</h3><p>A2UI provides an official React renderer and a shared web core library.</p><p>The relevant packages are:</p><ul><li>@a2ui/react</li><li>@a2ui/web_core</li></ul><h3>Step 1: Install the packages</h3><pre>pnpm add @a2ui/react @a2ui/web_core</pre><h3>Step 2: Create a MessageProcessor</h3><p>The MessageProcessor handles protocol messages, surface lifecycle, component definitions, and data models.</p><p>For the v0.9 implementation:</p><pre>import { useState } from &#39;react&#39;;<br><br>import {<br>  MessageProcessor,<br>} from &#39;@a2ui/web_core/v0_9&#39;;<br><br>import {<br>  A2uiSurface,<br>  basicCatalog,<br>} from &#39;@a2ui/react/v0_9&#39;;</pre><p>The processor is initialized with the component catalog that the application allows the agent to use.</p><pre>const processor = new MessageProcessor([<br>  basicCatalog,<br>]);</pre><h3>Step 3: Process agent-generated messages</h3><p>For example, we can create a surface and populate it with components:</p><pre>processor.processMessages([<br>  {<br>    version: &#39;v0.9&#39;,<br>    createSurface: {<br>      surfaceId: &#39;main&#39;,<br>      catalogId: basicCatalog.id,<br>    },<br>  },<br>  {<br>    version: &#39;v0.9&#39;,<br>    updateComponents: {<br>      surfaceId: &#39;main&#39;,<br>      components: [<br>        {<br>          id: &#39;root&#39;,<br>          component: &#39;Column&#39;,<br>          children: [&#39;heading&#39;, &#39;description&#39;],<br>        },<br>        {<br>          id: &#39;heading&#39;,<br>          component: &#39;Text&#39;,<br>          text: &#39;Hello from A2UI&#39;,<br>          variant: &#39;h1&#39;,<br>        },<br>        {<br>          id: &#39;description&#39;,<br>          component: &#39;Text&#39;,<br>          text: &#39;This interface was described by an agent.&#39;,<br>        },<br>      ],<br>    },<br>  },<br>]);</pre><p>The MessageProcessor maintains the surface and its component model.</p><h3>Step 4: Render the surface</h3><p>Once the surface has been created, the React renderer can display it.</p><pre>function DynamicUI() {<br>  const [processor] = useState(() =&gt; {<br>    const instance = new MessageProcessor([<br>      basicCatalog,<br>    ]);<br><br>    instance.processMessages([<br>      {<br>        version: &#39;v0.9&#39;,<br>        createSurface: {<br>          surfaceId: &#39;main&#39;,<br>          catalogId: basicCatalog.id,<br>        },<br>      },<br>      {<br>        version: &#39;v0.9&#39;,<br>        updateComponents: {<br>          surfaceId: &#39;main&#39;,<br>          components: [<br>            {<br>              id: &#39;root&#39;,<br>              component: &#39;Column&#39;,<br>              children: [&#39;title&#39;],<br>            },<br>            {<br>              id: &#39;title&#39;,<br>              component: &#39;Text&#39;,<br>              text: &#39;Generated Interface&#39;,<br>              variant: &#39;h1&#39;,<br>            },<br>          ],<br>        },<br>      },<br>    ]);<br><br>    return instance;<br>  });<br><br>  const surface = processor.model.surfacesMap.get(&#39;main&#39;);<br><br>  if (!surface) {<br>    return &lt;p&gt;Preparing interface...&lt;/p&gt;;<br>  }<br><br>  return &lt;A2uiSurface surface={surface} /&gt;;<br>}</pre><p>This is a minimal rendering example. A production implementation should create and manage the processor at the appropriate application or session scope, rather than reconstructing it for each update.</p><p>As new A2UI messages arrive, the application can pass them to the same processor. The surface model drives the React renderer’s updates.</p><h3>Step 5: Handle user interactions</h3><p>An interactive UI needs a return path.</p><p>For example, the agent may generate a form containing a confirmation button.</p><p>When the user clicks that button, the frontend captures the associated action and sends it back to the agent through the selected transport.</p><p>The flow becomes:</p><pre>Agent Generates UI<br>       |<br>       v<br>A2UI Renderer<br>       |<br>       v<br>User Interacts<br>       |<br>       v<br>Frontend Captures Action<br>       |<br>       v<br>Application / Agent Endpoint<br>       |<br>       v<br>Agent Processes Input<br>       |<br>       v<br>New A2UI Messages<br>       |<br>       v<br>Updated Interface</pre><p>A2UI v0.9.1 uses an external communication channel for these return interactions. Its v1.0 candidate specification introduces additional structured function-call and response mechanisms.</p><p>This distinction matters when designing the frontend-backend contract.</p><p><strong>References:</strong> <a href="https://github.com/a2ui-project/a2ui/tree/main/renderers/react">A2UI React Renderer</a> · <a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/guides/client-setup.md">A2UI Client Setup</a></p><h3>9. What Is the Difference Between AG-UI and A2UI?</h3><p>This is probably the most important distinction in the article.</p><p>AG-UI and A2UI are complementary, but they are not interchangeable.</p><p>AreaAG-UIA2UIPrimary purposeAgent-frontend communicationDeclarative UI generationMain abstractionTyped eventsUI surfaces and componentsData flowRun events and state updatesUI structure and data updatesMain responsibilityInteraction lifecycleInterface representationFrontend roleConsume and handle eventsRender approved componentsUser interactionAgent runs, tools, state and interruptsUI actions and data context</p><p>Consider an agent that needs to create a dynamic approval dashboard.</p><p>AG-UI can communicate:</p><ul><li>The agent has started processing.</li><li>The data retrieval step is complete.</li><li>A tool has returned results.</li><li>The agent has updated its state.</li><li>A user approval is required.</li></ul><p>A2UI can describe:</p><ul><li>An approval summary card.</li><li>A table of requested changes.</li><li>A confirmation button.</li><li>A detailed information panel.</li></ul><p>The frontend can render both the interaction lifecycle and the generated interface.</p><h3>Can we use them together?</h3><p>Yes.</p><p>A possible architecture looks like this:</p><pre>                 User<br>                  |<br>                  v<br>            React Frontend<br>                  |<br>                  v<br>               AG-UI<br>             Event Stream<br>                  |<br>                  v<br>             Agent Runtime<br>                  |<br>                  v<br>                 LLM<br>                  |<br>            +-----+-----+<br>            |           |<br>            v           v<br>        AG-UI Events   A2UI JSON<br>            |           |<br>            +-----+-----+<br>                  |<br>                  v<br>            Frontend Host<br>                  |<br>                  v<br>            A2UI Renderer<br>                  |<br>                  v<br>           Native React UI</pre><p>AG-UI manages the interaction boundary. A2UI describes the interface that should be rendered.</p><p>One protocol does not replace the other.</p><p>A2UI can also be transported over A2A, MCP-related integrations, SSE, or WebSockets. It is designed to remain independent of a particular transport.</p><p><strong>References:</strong> <a href="https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/concepts/architecture.mdx">AG-UI Architecture</a> · <a href="https://github.com/a2ui-project/a2ui/blob/main/specification/v0_9_1/docs/a2ui_protocol.md">A2UI Transport Decoupling</a></p><h3>10. How Do They Fit Into the Existing AI Ecosystem?</h3><p>We have already discussed MCP, ACP, and A2A in previous articles.</p><p>Adding AG-UI and A2UI gives us another important architectural layer.</p><p>TechnologyResponsibilityMCPAgent ↔ Tools and external dataACPCoding Agent ↔ Client ApplicationA2AAgent ↔ AgentAG-UIAgent ↔ Frontend interactionA2UIAgent → Declarative UI</p><p>A typical enterprise AI platform could use several of these technologies simultaneously.</p><p>For example:</p><ol><li>An application communicates with an agent using AG-UI.</li><li>The agent accesses internal services through MCP.</li><li>The agent delegates a specialized task to another agent using A2A.</li><li>The agent returns dynamic interface definitions using A2UI.</li><li>The application renders those definitions through its own component system.</li></ol><p>ACP can be used independently when we need to integrate coding agents with a client application.</p><p>This is not about adopting every protocol. It is about keeping architectural boundaries clear.</p><h3>11. How Do These Approaches Compare With Traditional Generative UI?</h3><p>AG-UI and A2UI are not the only ways to build agent-powered interfaces.</p><p>There are several approaches worth understanding.</p><h3>AI SDK Tool-Based UI</h3><p>Vercel AI SDK already allows applications to render custom UI based on tool calls and structured message parts.</p><p>For example, a model can call a weather tool, and the frontend can render a weather card instead of displaying only raw text.</p><p>This is a good approach when:</p><ul><li>The application has a known set of components.</li><li>UI behavior is closely tied to model tool calls.</li><li>The application owns the entire interaction contract.</li></ul><p>However, it does not automatically provide the same cross-framework UI specification and agent interaction model as AG-UI and A2UI.</p><h3>MCP Apps</h3><p>MCP Apps extend MCP tools with interactive UI experiences.</p><p>A tool can expose a ui:// resource containing its interface, which an MCP host can render in a sandboxed iframe.</p><p>This is particularly useful when a tool needs to provide its own interactive experience, such as a dashboard, form, or specialized workflow.</p><p>It solves a different problem from A2UI: the UI is associated with a tool rather than being a general declarative UI surface generated by an agent.</p><h3>A2UI</h3><p>A2UI focuses on agent-generated interface descriptions and framework-specific rendering.</p><p>The same declarative description can be consumed by compatible clients with different native component implementations.</p><h3>AG-UI</h3><p>AG-UI focuses on the interaction protocol rather than the visual representation.</p><p>It can be used with ordinary chat interfaces, custom React components, or declarative generative UI approaches.</p><p>ApproachBest suited forAI SDK Tool UIApplication-defined component renderingMCP AppsInteractive UIs provided by individual toolsA2UIAgent-generated, declarative interfacesAG-UIStandardized agent-frontend communication</p><p>These approaches are not mutually exclusive.</p><p><strong>References:</strong> <a href="https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat">AI SDK — useChat</a> · <a href="https://vercel.com/kb/guide/ai-sdk-mcp-apps">Vercel — MCP Apps Integration</a> · <a href="https://a2ui.org/">Google A2UI</a></p><h3>12. Advantages and Trade-offs</h3><p>Introducing these technologies can improve architecture and developer experience, but they also introduce new responsibilities.</p><h3>AG-UI Advantages</h3><p><strong>Standardized event communication</strong></p><p>Teams can consume a consistent event model instead of maintaining separate custom streaming contracts.</p><p><strong>Frontend-backend separation</strong></p><p>The frontend does not need to understand the internal implementation of each agent framework.</p><p><strong>Better visibility into execution</strong></p><p>Tool calls, progress updates, and agent state can be represented in the UI.</p><p><strong>Framework interoperability</strong></p><p>Different backend agent implementations can expose a common interaction contract.</p><p><strong>Support for human-in-the-loop workflows</strong></p><p>Applications can build approval, clarification, and user-steering experiences around agent execution.</p><h3>AG-UI Trade-offs</h3><ul><li>Existing agent frameworks may require adapters or event translation.</li><li>Teams must manage protocol versions and event compatibility.</li><li>Application state and agent state can become inconsistent if ownership is unclear.</li><li>Authentication, authorization, and event validation remain application responsibilities.</li><li>For simple chat applications, a custom endpoint or ordinary AI SDK integration may be sufficient.</li></ul><h3>A2UI Advantages</h3><p><strong>Declarative and safer UI generation</strong></p><p>Agents send structured data rather than executable React or JavaScript code.</p><p><strong>Framework independence</strong></p><p>The same UI description can be mapped to different client-side component systems.</p><p><strong>Reuse of existing design systems</strong></p><p>Applications can expose their own approved component catalog.</p><p><strong>Incremental rendering</strong></p><p>The UI structure and data can be updated without resending everything.</p><p><strong>Separation of structure and data</strong></p><p>Components can remain stable while the agent updates the underlying data model.</p><h3>A2UI Trade-offs</h3><ul><li>A component catalog must be designed and maintained.</li><li>The renderer must support the required component types.</li><li>Complex application-specific interactions require additional integration.</li><li>Schema and protocol version compatibility require attention.</li><li>Debugging streamed component updates can be more difficult than debugging ordinary React components.</li><li>Cross-platform rendering does not guarantee identical visual behavior on every platform.</li></ul><p>An important point: declarative JSON is safer than arbitrary executable code, but it is not automatically trustworthy.</p><p>The application must still validate messages, restrict available components, and authorize actions.</p><h3>13. Security and Production Considerations</h3><p>This is where the difference between a successful demo and a maintainable production implementation becomes important.</p><h3>1. Never execute arbitrary generated code</h3><p>An agent should not be able to send arbitrary JavaScript, HTML, or React code and have the application execute it.</p><p>A2UI’s declarative component catalog provides a safer approach.</p><p>The application should only render components it explicitly supports.</p><h3>2. Validate incoming messages</h3><p>AG-UI events and A2UI messages must be treated as untrusted input.</p><p>Validate event structures, protocol versions, component definitions, data paths, and payload sizes before processing them.</p><h3>3. Control available components</h3><p>Not every component should be available to an agent.</p><p>For example, an internal banking application might allow:</p><ul><li>Summary cards</li><li>Data tables</li><li>Charts</li><li>Read-only forms</li><li>Approval request components</li></ul><p>However, a sensitive transaction operation should not become available merely because the agent can generate a button.</p><p>The backend must authorize the actual business operation.</p><h3>4. Keep application state under application control</h3><p>There should be a clear distinction between UI state, agent state, and authoritative business state.</p><p>An agent-generated UI should not be considered a source of truth for financial transactions, permissions, or other business-critical information.</p><h3>5. Require human approval for sensitive actions</h3><p>A dynamically generated interface can make it easier for an agent to request user confirmation.</p><p>However, the approval must be verified on the backend. Hiding or disabling a frontend button is not an authorization mechanism.</p><h3>6. Handle interrupted streams and reconnection</h3><p>Streaming applications need predictable recovery behavior.</p><p>Consider:</p><ul><li>Reconnecting after network failures</li><li>Handling duplicate events</li><li>Recovering from incomplete UI updates</li><li>Preserving task and surface identifiers</li><li>Handling cancellation</li><li>Expiring stale sessions</li></ul><p>AG-UI and A2UI define communication structures, but the application still needs an operational strategy for stream recovery and state consistency.</p><h3>7. Audit agent-generated interactions</h3><p>For enterprise applications, it should be possible to understand:</p><ul><li>Which agent generated a UI.</li><li>Which components were requested.</li><li>Which actions the user performed.</li><li>Which backend operations were executed.</li><li>Whether an approval was obtained.</li><li>What the final result was.</li></ul><p>This is particularly important when AI interfaces are used to interact with internal business systems.</p><h3>14. What Can an AI Chapter Lead Contribute?</h3><p>I believe this is where these technologies become particularly valuable.</p><p>The opportunity is not simply introducing generative UI into an application. It is establishing a reusable frontend-agent architecture that product teams can build on.</p><h3>1. Establish a Shared Agent-Frontend Integration Layer</h3><p>Instead of every team implementing its own event handling and agent-state synchronization, we can define a shared integration layer based on AG-UI.</p><p>It can provide:</p><ul><li>Event subscription and processing</li><li>Streaming message handling</li><li>Tool execution indicators</li><li>State synchronization</li><li>Cancellation and error handling</li><li>Human approval workflows</li></ul><p>This reduces duplicated infrastructure across applications.</p><h3>2. Create a Governed Component Catalog</h3><p>A2UI introduces an interesting opportunity to connect agent capabilities with an organization’s existing design system.</p><p>For example, we can define an approved component catalog:</p><p>ComponentPurposeSummaryCardDisplay structured summariesDataTableRender tabular informationLineChartDisplay time-series dataApprovalPanelPresent approval requestsDateRangePickerCollect date filtersStatusIndicatorDisplay execution status</p><p>The agent can request these components, while the frontend maps them to existing React components.</p><p>This means teams can introduce dynamic interfaces without giving the model unrestricted access to the frontend codebase.</p><p>For organizations with established design systems, this could be particularly valuable.</p><h3>3. Define Reusable Frontend Patterns</h3><p>We can establish standard patterns for:</p><ul><li>Agent progress indicators</li><li>Interactive forms</li><li>Approval interfaces</li><li>Dynamic dashboards</li><li>Multi-step workflows</li><li>Tool execution views</li><li>Subagent activity panels</li></ul><p>These patterns can become shared components and examples that product teams reuse.</p><h3>4. Standardize the Security Model</h3><p>A shared approach should define which components agents can request, which actions require approval, and how the backend validates every operation.</p><p>This is especially important for applications interacting with sensitive business systems.</p><h3>5. Build a Reference Implementation</h3><p>A small reference application could demonstrate the complete interaction:</p><ol><li>A user asks an agent to analyze a dataset.</li><li>The agent starts execution and sends AG-UI events.</li><li>The frontend displays progress and activity.</li><li>The agent generates an A2UI dashboard.</li><li>The React renderer maps the description to approved components.</li><li>The user modifies a filter or submits an action.</li><li>The frontend sends the interaction back to the agent.</li><li>The agent updates the interface with new results.</li></ol><p>This would help developers understand how the protocols work together in a real application.</p><h3>6. Measure the Developer and User Experience</h3><p>We should not evaluate generative UI only by how quickly an agent can produce a visual result.</p><p>Useful metrics include:</p><ul><li>Time to interactive UI</li><li>Agent-to-frontend event latency</li><li>UI schema validation failure rate</li><li>Unsupported component requests</li><li>Rendering errors</li><li>User interaction completion rate</li><li>Human approval rate</li><li>Frontend integration effort</li></ul><p>These metrics can help determine whether generative UI actually improves the application experience.</p><h3>15. What Is the Current State of A2UI and AG-UI?</h3><p>Both projects are evolving, so protocol versioning matters.</p><p>As of November 1, 2026:</p><h3>AG-UI</h3><p>AG-UI publishes a versioned v1.0 specification and provides TypeScript client libraries, event schemas, and integrations with different agent frameworks.</p><p>Its ecosystem includes integrations with projects such as LangGraph, CrewAI, Google ADK, and other agent runtimes.</p><h3>A2UI</h3><p>A2UI currently identifies v0.9.1 as its production protocol release.</p><p>The v1.0 specification is a candidate release, not the production version.</p><p>The project also provides an official React renderer, shared web core, Lit renderer, Angular renderer, and Flutter-based support.</p><p>There is an important migration consideration: the v1.0 candidate introduces changes to the protocol model and should not be assumed to be wire-compatible with v0.9.1.</p><p>For production applications, I would start with the stable v0.9.1 protocol and its matching renderer, and evaluate v1.0 separately before planning a migration.</p><p><strong>References:</strong> <a href="https://github.com/a2ui-project/a2ui">A2UI Repository and Status</a> · <a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/roadmap.md">A2UI Roadmap</a> · <a href="https://github.com/ag-ui-protocol/ag-ui">AG-UI Specification</a></p><h3>Conclusion</h3><p>AG-UI and A2UI address two important challenges in building modern agent-powered applications.</p><p>AG-UI standardizes the interaction between agents and frontend applications through a structured, event-driven communication model.</p><p>A2UI allows agents to describe dynamic interfaces using declarative JSON messages, while applications remain in control of the actual components and rendering process.</p><p>Together, they make it possible to move beyond traditional chat interfaces and build applications where AI agents can communicate their progress, update application state, and generate context-aware user interfaces.</p><p>For engineering teams, the opportunity is to establish reusable interaction patterns, govern agent-accessible components, maintain security boundaries, and integrate generative UI into existing design systems.</p><p><strong>My recommendation:</strong> Start with a small React application that uses AG-UI for streaming and state synchronization, then introduce A2UI for one clearly defined dynamic interface. Evaluate rendering reliability, security, and user experience before expanding the approach.</p><p>The goal should not be to make every screen dynamic.</p><p>It should be to make the interface adapt when a conversation or workflow genuinely requires it.</p><h3>References and Further Reading</h3><h3>AG-UI</h3><ol><li><a href="https://docs.ag-ui.com/">AG-UI Official Documentation</a> — Introduction, architecture, events, and integration.</li><li><a href="https://github.com/ag-ui-protocol/ag-ui">AG-UI GitHub Repository</a> — Protocol implementation and SDKs.</li><li><a href="https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/concepts/architecture.mdx">AG-UI Architecture</a> — Event-driven architecture and client-server communication.</li><li><a href="https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/concepts/events.mdx">AG-UI Event Reference</a> — Lifecycle, messages, tools, and state events.</li><li><a href="https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/typescript">AG-UI TypeScript SDK</a> — Client implementation and event handling.</li></ol><h3>A2UI</h3><ol><li><a href="https://a2ui.org/">Google A2UI Official Documentation</a> — Project overview and developer resources.</li><li><a href="https://github.com/a2ui-project/a2ui/blob/main/specification/v0_9_1/docs/a2ui_protocol.md">A2UI Protocol v0.9.1</a> — JSON protocol, surfaces, components, and data models.</li><li><a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/quickstart.md">A2UI Quickstart</a> — Full-stack example and generated UI workflow.</li><li><a href="https://github.com/a2ui-project/a2ui/tree/main/renderers/react">A2UI React Renderer</a> — React rendering implementation.</li><li><a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/guides/client-setup.md">A2UI Client Setup</a> — Framework integration and component catalogs.</li><li><a href="https://github.com/a2ui-project/a2ui/blob/main/docs/public/guides/renderer-development.md">A2UI Renderer Development</a> — Building custom renderers and integrating design systems.</li><li><a href="https://github.com/a2ui-project/a2ui/blob/main/specification/v1_0/docs/a2ui_protocol.md">A2UI v1.0 Candidate Specification</a> — Upcoming protocol capabilities and changes.</li></ol><h3>Related Technologies</h3><ol><li><a href="https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat">Vercel AI SDK — useChat</a> — Conversational streaming and frontend state.</li><li><a href="https://vercel.com/kb/guide/ai-sdk-mcp-apps">Vercel — MCP Apps Integration</a> — Interactive tool UIs in AI SDK applications.</li><li><a href="https://docs.copilotkit.ai/shared-state">CopilotKit — Shared State</a> — Bidirectional state synchronization between React and agents.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5bd28088acda" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Adaptive Concurrency Limits in Go: Dynamically Protecting Dependencies Under Load]]></title>
            <link>https://medium.com/@serifcolakel/adaptive-concurrency-limits-in-go-dynamically-protecting-dependencies-under-load-b5c364c712b4?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/b5c364c712b4</guid>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[productivity]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Sun, 01 Nov 2026 13:52:47 GMT</pubDate>
            <atom:updated>2026-11-01T13:52:47.228Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yBoMh2BQDW1WeXWMjuGVKw.png" /><figcaption>Adaptive Concurrency Limits in Go: Dynamically Protecting Dependencies Under Load Image</figcaption></figure><p>Most services eventually need a concurrency limit.</p><p>You might start with something simple:</p><pre>const maxConcurrentRequests = 100</pre><p>A semaphore ensures that no more than 100 requests execute simultaneously.</p><p>It works. Until the conditions change.</p><p>Your service gets more CPU resources after scaling. A downstream database becomes slower. A new deployment changes request processing time. Traffic patterns shift throughout the day.</p><p>The limit that worked yesterday might be too restrictive today. Or worse, it might be too high and allow a dependency to become overloaded.</p><p>Static concurrency limits are easy to implement, but they require continuous tuning.</p><p>What if a service could adjust its concurrency limit automatically based on how the dependency behaves?</p><p>That’s the idea behind <strong>adaptive concurrency limiting</strong>.</p><p>Instead of relying entirely on a predefined number, the system observes request latency and failure signals, estimates when queueing is happening, and adjusts the number of requests it allows in flight.</p><p>In this article, we’ll explore:</p><ul><li>Why fixed concurrency limits eventually become difficult to maintain</li><li>The relationship between concurrency, throughput, and latency</li><li>How TCP congestion control inspired adaptive concurrency algorithms</li><li>How to implement a latency-aware concurrency limiter in Go</li><li>How to integrate it into an HTTP client</li><li>How adaptive limits interact with backpressure, bulkheads, and retry budgets</li><li>What to monitor and test before using adaptive concurrency in production</li></ul><h3>1. The Problem with Fixed Concurrency Limits</h3><p>Imagine a Go service that calls an external market data API.</p><p>You initially configure a concurrency limit of 50.</p><pre>limiter := NewFixedLimiter(50)</pre><p>Under normal traffic, the dependency responds in 40ms.</p><p>Everything looks healthy.</p><p>Then traffic increases.</p><p>The dependency starts processing requests more slowly. Its response time grows to 150ms, then 300ms.</p><p>Your service still allows 50 concurrent requests.</p><p>The problem is that concurrency and latency are connected.</p><p>When requests take longer to complete, existing requests occupy concurrency slots for longer. New requests accumulate, queues grow, and latency increases further.</p><p>A fixed limit cannot automatically distinguish between:</p><ul><li>A dependency that has additional capacity</li><li>A dependency approaching saturation</li><li>A dependency already experiencing queueing</li><li>A dependency recovering from an overload</li></ul><p>You can increase the limit manually, but that risks overloading the dependency.</p><p>You can decrease it, but that might unnecessarily reduce throughput.</p><p>The challenge is finding a concurrency level that allows high throughput without creating excessive queueing.</p><h3>2. Why Concurrency Matters More Than RPS Alone</h3><p>Rate limiting and concurrency limiting solve different problems.</p><p>A rate limiter controls how many requests can start during a period.</p><p>A concurrency limiter controls how many requests can be in progress simultaneously.</p><p>Consider two situations:</p><p>ScenarioRequest rateAverage latencyApprox. concurrencyHealthy dependency500 req/s40ms20Slow dependency500 req/s200ms100</p><p>Both scenarios have the same incoming request rate.</p><p>But the second requires five times as many concurrent requests to sustain that throughput.</p><p>This relationship is described by Little’s Law:</p><p>[ L = \lambda W ]</p><p>Where:</p><ul><li>(L) is the average number of items in the system</li><li>(\lambda) is the average arrival rate</li><li>(W) is the average time spent in the system</li></ul><p>For request processing:</p><p>[ Concurrency \approx Throughput \times Latency ]</p><p>This explains why a fixed RPS limit is not always sufficient to protect a dependency.</p><p>A service can receive an acceptable number of requests per second while still accumulating too many in-flight operations because response times have increased.</p><p>Adaptive concurrency focuses on this relationship.</p><h3>3. The Idea Behind Adaptive Concurrency</h3><p>The central idea is straightforward:</p><ol><li>Measure request latency.</li><li>Maintain an estimate of normal, unloaded latency.</li><li>Compare current latency against that baseline.</li><li>Increase concurrency when the dependency has spare capacity.</li><li>Reduce concurrency when latency indicates queueing or failures.</li></ol><p>This resembles congestion control in TCP.</p><p>TCP does not blindly send unlimited data. It maintains a congestion window and adjusts how much data can be in flight based on network conditions.</p><p>Adaptive concurrency applies a similar idea to service requests.</p><p>The concurrency limit becomes a dynamic window.</p><p>&lt;figure&gt; &lt;img src=”https://dummyimage.com/1200x500/101827/00eaff.png&amp;text=Adaptive+Concurrency%3A+Measure+%E2%86%92+Adjust+%E2%86%92+Protect&quot; alt=”Adaptive concurrency control loop” /&gt; &lt;/figure&gt;</p><p>There are several algorithms worth understanding.</p><h3>AIMD</h3><p>Additive Increase, Multiplicative Decrease.</p><ul><li>Increase the limit gradually when requests succeed.</li><li>Reduce it significantly when failures or overload signals appear.</li></ul><p>For example:</p><pre>Current limit: 50<br><br>Successful samples:<br>50 → 51 → 52 → 53<br><br>Overload detected:<br>53 → 37</pre><p>AIMD is relatively simple and works well when the system has reliable failure signals.</p><p>However, it may react only after a dependency starts rejecting requests or timing out.</p><h3>Vegas</h3><p>Vegas uses latency as an early signal of congestion.</p><p>It estimates queueing using the difference between observed latency and a no-load baseline.</p><p>A simplified queue estimate is:</p><p>[ Q = L \times \left(1 — \frac{RTT<em>{baseline}}{RTT</em>{current}}\right) ]</p><p>Where:</p><ul><li>(Q) is estimated queue size</li><li>(L) is the current concurrency limit</li><li>(RTT_{baseline}) is the observed no-load latency</li><li>(RTT_{current}) is the current measured latency</li></ul><p>When latency stays close to the baseline, the algorithm can increase concurrency.</p><p>When latency grows significantly, it reduces the limit.</p><h3>Gradient2</h3><p>Gradient2 compares short-term and long-term latency averages to detect changes in the latency trend.</p><p>It is useful when latency baselines drift over time or workloads are bursty.</p><p>Instead of relying only on a minimum latency measurement, it uses smoothed averages to identify growing queueing pressure.</p><p>For a deeper implementation reference, Netflix’s concurrency-limits project is worth exploring:</p><p><a href="https://github.com/Netflix/concurrency-limits">https://github.com/Netflix/concurrency-limits</a></p><p>For this article, we’ll build a smaller Vegas-inspired controller. It is an educational implementation, not a complete reproduction of Netflix’s Vegas or Gradient2 algorithms.</p><h3>4. Building an Adaptive Limiter in Go</h3><p>Let’s implement a limiter that:</p><ul><li>Tracks in-flight requests</li><li>Enforces a dynamic concurrency limit</li><li>Measures request duration</li><li>Collects latency samples in windows</li><li>Estimates queueing using a baseline latency</li><li>Increases or decreases the limit based on observed behavior</li></ul><p>We’ll use a fail-fast admission strategy.</p><p>When the current concurrency limit is reached, the limiter rejects the new request instead of allowing an unbounded queue to form.</p><h3>Step 1: Define the limiter</h3><pre>package adaptive<br><br>import (<br>    &quot;errors&quot;<br>    &quot;sync&quot;<br>    &quot;time&quot;<br>)<br><br>var ErrLimitExceeded = errors.New(<br>    &quot;adaptive concurrency limit exceeded&quot;,<br>)<br><br>type Limiter struct {<br>    mu sync.Mutex<br>    limit    int<br>    minLimit int<br>    maxLimit int<br>    inflight int<br>    peak     int<br>    baseline time.Duration<br>    sampleCount int<br>    sampleTotal time.Duration<br>    dropped     bool<br>    windowSize int<br>    alpha float64<br>    beta  float64<br>}<br><br>func New(<br>    initialLimit int,<br>    minLimit int,<br>    maxLimit int,<br>) *Limiter {<br>    if minLimit &lt; 1 ||<br>        initialLimit &lt; minLimit ||<br>        maxLimit &lt; initialLimit {<br>        panic(&quot;invalid concurrency limits&quot;)<br>    }<br><br>    return &amp;Limiter{<br>        limit:      initialLimit,<br>        minLimit:   minLimit,<br>        maxLimit:   maxLimit,<br>        windowSize: 20,<br>        alpha:      2,<br>        beta:       4,<br>    }<br>}</pre><p>The limiter maintains two important pieces of information:</p><p><strong>Current limit:</strong> The maximum number of requests allowed simultaneously.</p><p><strong>In-flight count:</strong> The number of requests currently executing.</p><p>We also maintain latency statistics and a baseline.</p><p>The windowSize determines how many completed requests we collect before updating the concurrency limit.</p><p>Using a window helps avoid changing the limit after every individual request.</p><h3>Step 2: Acquire a permit</h3><p>The limiter needs to decide whether a new request can execute.</p><pre>func (l *Limiter) Acquire() (func(bool), error) {<br>    l.mu.Lock()<br><br>    if l.inflight &gt;= l.limit {<br>        l.mu.Unlock()<br>        return nil, ErrLimitExceeded<br>    }<br><br>    l.inflight++<br><br>    if l.inflight &gt; l.peak {<br>        l.peak = l.inflight<br>    }<br><br>    l.mu.Unlock()<br><br>    startedAt := time.Now()<br><br>    var once sync.Once<br><br>    done := func(dropped bool) {<br>        once.Do(func() {<br>            elapsed := time.Since(startedAt)<br>            l.mu.Lock()<br>            defer l.mu.Unlock()<br>            l.inflight--<br>            l.recordSample(elapsed, dropped)<br>        })<br>    }<br><br>    return done, nil<br>}</pre><p>The returned function releases the permit and reports the outcome.</p><p>For example:</p><pre>done, err := limiter.Acquire()<br>if err != nil {<br>    return err<br>}<br><br>defer done(false)<br><br>return callDependency()</pre><p>If the operation experiences a dependency-level overload signal, we can report that separately:</p><pre>done, err := limiter.Acquire()<br>if err != nil {<br>    return err<br>}<br><br>defer func() {<br>    done(isOverloadError(err))<br>}()<br><br>err = callDependency()<br><br>return err</pre><p>The sync.Once protects against accidentally releasing the same permit twice.</p><p>The caller must invoke the returned function exactly once in normal control flow. The sync.Once makes repeated invocations harmless.</p><h3>Step 3: Record latency samples</h3><p>Now we need to collect completed request samples.</p><pre>func (l *Limiter) recordSample(<br>    latency time.Duration,<br>    dropped bool,<br>) {<br>    l.sampleCount++<br>    l.sampleTotal += latency<br><br>    if dropped {<br>        l.dropped = true<br>    }<br>    if l.sampleCount &lt; l.windowSize {<br>        return<br>    }<br>    average := l.sampleTotal / time.Duration(l.sampleCount)<br>    l.updateLimit(average)<br>    l.sampleCount = 0<br>    l.sampleTotal = 0<br>    l.dropped = false<br>    l.peak = 0<br>}</pre><p>Instead of adjusting concurrency after every request, we wait until a complete sample window is collected.</p><p>This reduces sensitivity to individual latency spikes.</p><p>A production implementation may use more sophisticated rolling windows, exponential moving averages, or percentile-based measurements.</p><h3>Step 4: Implement the control algorithm</h3><p>Now comes the interesting part.</p><p>We compare the current average latency against the baseline.</p><pre>func (l *Limiter) updateLimit(<br>    currentLatency time.Duration,<br>) {<br>    if currentLatency &lt;= 0 {<br>        return<br>    }<br><br>    if l.baseline == 0 ||<br>        currentLatency &lt; l.baseline {<br>        l.baseline = currentLatency<br>        return<br>    }<br>    if l.dropped {<br>        l.decrease()<br>        return<br>    }<br>    queueEstimate := float64(l.limit) *<br>        (1 - float64(l.baseline)/float64(currentLatency))<br>    // Increase only when the previous window<br>    // demonstrated meaningful concurrency usage.<br>    if l.peak*2 &lt; l.limit {<br>        return<br>    }<br>    switch {<br>    case queueEstimate &lt; l.alpha:<br>        l.increase()<br>    case queueEstimate &gt; l.beta:<br>        l.decrease()<br>    }<br>}</pre><p>The queue estimate is an approximation of the queueing pressure observed at the dependency.</p><p>When current latency is close to the baseline, the estimated queue remains small.</p><p>When current latency increases, the estimated queue grows.</p><p>We use two thresholds:</p><ul><li>alpha: Below this value, the dependency appears to have spare capacity.</li><li>beta: Above this value, queueing pressure is considered too high.</li></ul><p>The gap between the thresholds creates a stability region where the limit does not change.</p><p>This helps avoid unnecessary oscillation.</p><h3>Step 5: Increase and decrease concurrency</h3><p>The final methods enforce the configured boundaries.</p><pre>func (l *Limiter) increase() {<br>    if l.limit &lt; l.maxLimit {<br>        l.limit++<br>    }<br>}<br><br>func (l *Limiter) decrease() {<br>    next := int(float64(l.limit) * 0.7)<br>    if next &lt; l.minLimit {<br>        next = l.minLimit<br>    }<br>    l.limit = next<br>}</pre><p>The limiter uses:</p><ul><li>Additive increase: Grow gradually.</li><li>Multiplicative decrease: Reduce more aggressively when congestion appears.</li></ul><p>This is intentionally conservative.</p><p>Increasing the limit by one allows the dependency to demonstrate additional capacity without creating a large traffic spike.</p><p>Reducing the limit by 30% provides a faster response to overload.</p><p>These values are configuration choices, not universal production defaults.</p><h3>Step 6: Expose the current limit</h3><p>We should expose the current state for observability.</p><pre>func (l *Limiter) Stats() (<br>    limit int,<br>    inflight int,<br>    baseline time.Duration,<br>) {<br>    l.mu.Lock()<br>    defer l.mu.Unlock()<br><br>    return l.limit, l.inflight, l.baseline<br>}</pre><p>Now we can inspect the limiter without exposing its internal synchronization.</p><p>For example:</p><pre>limit, inflight, baseline := limiter.Stats()<br><br>log.Printf(<br>    &quot;adaptive limiter: limit=%d inflight=%d baseline=%s&quot;,<br>    limit,<br>    inflight,<br>    baseline,<br>)</pre><h3>5. Integrating the Limiter with an HTTP Client</h3><p>Let’s apply the limiter to an external dependency.</p><pre>type MarketDataClient struct {<br>    httpClient *http.Client<br>    limiter    *adaptive.Limiter<br>}<br><br>func (c *MarketDataClient) Fetch(<br>    ctx context.Context,<br>    symbol string,<br>) error {<br>    done, err := c.limiter.Acquire()<br>    if err != nil {<br>        return err<br>    }<br><br>    var dropped bool<br><br>    defer func() {<br>        done(dropped)<br>    }()<br>    req, err := http.NewRequestWithContext(<br>        ctx,<br>        http.MethodGet,<br>        &quot;https://api.example.com/market/&quot;+symbol,<br>        nil,<br>    )<br><br>    if err != nil {<br>        return err<br>    }<br>    resp, err := c.httpClient.Do(req)<br><br>    if err != nil {<br>        dropped = isDependencyOverload(err)<br>        return err<br>    }<br><br>    defer resp.Body.Close()<br><br>    if resp.StatusCode == http.StatusTooManyRequests ||<br>        resp.StatusCode &gt;= http.StatusServiceUnavailable {<br>        dropped = true<br>        return fmt.Errorf(<br>            &quot;market data dependency returned %d&quot;,<br>            resp.StatusCode,<br>        )<br>    }<br><br>    if resp.StatusCode &lt; 200 || resp.StatusCode &gt;= 300 {<br>        return fmt.Errorf(<br>            &quot;unexpected status: %d&quot;,<br>            resp.StatusCode,<br>        )<br>    }<br><br>    return nil<br>}</pre><p>The example classifies overload responses separately from other errors.</p><p>This distinction matters.</p><p>A validation error, for example, should not necessarily cause the adaptive limiter to reduce its concurrency limit.</p><p>Likewise, a network timeout might be an overload signal in one system but a network infrastructure issue in another.</p><p>The failure classifier should reflect the dependency’s actual behavior.</p><p>The HTTP client should also have appropriate timeouts and connection settings. An adaptive concurrency limiter is not a replacement for transport-level protection.</p><h3>6. What Happens During an Overload?</h3><p>Let’s walk through a simplified scenario.</p><p>Assume the limiter starts with a concurrency limit of 50.</p><p>The dependency initially responds in 40ms.</p><p>As traffic increases, the limiter observes the following:</p><p>StageAverage latencyConcurrency limitInitial40ms50Increased traffic45ms51Higher utilization55ms52Queueing begins120ms36Dependency recovers45msGradually increases</p><p>The important behavior is not the exact numbers.</p><p>The limiter detects that latency is increasing relative to the baseline and reduces the amount of work it allows in flight.</p><p>As the dependency recovers, the limiter can gradually explore additional capacity again.</p><p>This is a feedback control loop.</p><p>However, it is important to understand that the limiter does not instantly discover the perfect concurrency limit. It continuously estimates a useful operating point based on observed signals.</p><h3>7. Adaptive Concurrency vs. Other Resilience Patterns</h3><p>Adaptive concurrency is not a replacement for the patterns we’ve discussed in previous articles.</p><p>It complements them.</p><p>MechanismResponsibilityBackpressureControls incoming workLoad sheddingRejects work when capacity is exceededBulkheadIsolates resources and dependenciesRetry budgetLimits additional retry trafficAdaptive concurrencyAdjusts the concurrency ceiling based on observed conditions</p><p>Consider a service that communicates with three dependencies:</p><ul><li>Market Data API</li><li>Portfolio Database</li><li>Reporting Service</li></ul><p>Each dependency has different latency characteristics and capacity.</p><p>Using one global concurrency limit would couple their behavior.</p><p>A better approach is to create an independent adaptive limiter for each dependency.</p><pre>type DependencyLimiters struct {<br>    MarketData *adaptive.Limiter<br>    Portfolio  *adaptive.Limiter<br>    Reporting  *adaptive.Limiter<br>}</pre><p>This gives each dependency its own feedback loop.</p><p>If the reporting service becomes slow, its concurrency limit can decrease without automatically reducing the capacity available to market data requests.</p><p>This is where adaptive concurrency and bulkheads work particularly well together.</p><h3>8. Important Production Considerations</h3><p>The example implementation demonstrates the core idea, but a production adaptive limiter needs additional safeguards.</p><h3>Baseline latency can become stale</h3><p>A latency baseline is only useful when it represents a reasonably unloaded system.</p><p>If the baseline is established while the dependency is already overloaded, the algorithm may incorrectly treat unhealthy latency as normal.</p><p>Production implementations should carefully manage baseline updates and resets.</p><h3>Traffic must be representative</h3><p>A dependency may expose different latency characteristics for different operations.</p><p>For example:</p><ul><li>Reading a cached market price</li><li>Fetching historical prices</li><li>Calculating a portfolio</li><li>Executing a database aggregation</li></ul><p>Combining all these operations into one latency signal can produce misleading results.</p><p>Consider separate limiters or traffic partitions when workloads have significantly different behavior.</p><h3>The algorithm needs enough samples</h3><p>Adaptive control depends on observations.</p><p>A low-traffic service might not generate enough samples to update its limit reliably.</p><p>A new instance also needs a warm-up strategy.</p><p>Slow start, conservative initial limits, and minimum sample requirements can help.</p><h3>Avoid uncontrolled oscillation</h3><p>A limiter that constantly increases and decreases its capacity can introduce instability.</p><p>Useful techniques include:</p><ul><li>Sampling windows</li><li>Exponential smoothing</li><li>Separate increase and decrease thresholds</li><li>Bounded increase and decrease rates</li><li>Minimum and maximum concurrency limits</li><li>Jitter when multiple independent clients share a dependency</li></ul><h3>Local limits multiply across replicas</h3><p>This is especially important in distributed deployments.</p><p>Imagine ten replicas, each with an adaptive concurrency limit of 100.</p><p>The dependency may receive up to 1,000 concurrent requests.</p><p>Each instance is making decisions based on its own observations. The aggregate behavior is not automatically coordinated.</p><p>Local adaptive concurrency is often sufficient for protecting individual instances and reacting to local conditions.</p><p>But strict global dependency quotas may require a shared dispatcher, centralized coordination, or provider-side quota enforcement.</p><h3>Do not confuse concurrency with connection limits</h3><p>An HTTP transport’s connection pool and an application’s concurrency limiter protect different resources.</p><p>A request may hold an application permit while waiting for a connection. Conversely, HTTP/2 can multiplex multiple requests over one connection.</p><p>Configure both based on the actual workload.</p><h3>9. Testing Adaptive Concurrency</h3><p>Adaptive algorithms should be tested as control systems, not only as ordinary utility functions.</p><p>At minimum, test these behaviors:</p><ul><li>The initial concurrency limit is respected.</li><li>Requests are rejected when the current limit is reached.</li><li>Permits are released exactly once.</li><li>Successful low-latency windows allow gradual growth.</li><li>Rising latency causes the limit to decrease.</li><li>Overload signals trigger a decrease.</li><li>The limit never exceeds its configured maximum.</li><li>The limit never falls below its configured minimum.</li><li>Concurrent acquisitions are race-safe.</li><li>Insufficient sample windows do not trigger premature changes.</li></ul><p>Run the race detector:</p><pre>go test -race ./...</pre><p>Also consider deterministic algorithm tests.</p><p>Instead of using real HTTP requests and sleeping for arbitrary durations, separate the control algorithm from the admission mechanism.</p><p>Feed known latency samples into the controller and verify the resulting limit.</p><p>For example:</p><pre>Baseline: 40ms<br><br>Window 1: 42ms → increase<br>Window 2: 45ms → increase<br>Window 3: 120ms → decrease<br>Window 4: 130ms → decrease<br>Window 5: 45ms → gradual recovery</pre><p>This makes algorithm behavior easier to reproduce and debug.</p><p>Finally, run load tests with changing dependency latency.</p><p>A useful test should simulate:</p><ol><li>A healthy dependency.</li><li>Gradually increasing latency.</li><li>Sudden overload.</li><li>Partial recovery.</li><li>A complete dependency outage.</li></ol><p>Measure throughput, p95/p99 latency, rejection rate, and concurrency limit changes.</p><p>The goal is not simply to maximize throughput.</p><p>The goal is to maintain useful throughput without allowing latency and queueing to grow uncontrollably.</p><h3>10. Observability</h3><p>An adaptive limiter introduces a new operational component.</p><p>You need visibility into how it makes decisions.</p><p>Useful metrics include:</p><p>MetricTypePurposeadaptive_concurrency_limitGaugeCurrent concurrency ceilingadaptive_inflight_requestsGaugeActive requestsadaptive_request_latency_secondsHistogramObserved dependency latencyadaptive_rejected_totalCounterRequests rejected at admissionadaptive_limit_increase_totalCounterLimit increase eventsadaptive_limit_decrease_totalCounterLimit decrease eventsadaptive_baseline_latency_secondsGaugeEstimated unloaded latency</p><p>These metrics help answer important questions:</p><ul><li>Is the limiter constantly operating at its maximum?</li><li>Is the concurrency limit frequently decreasing?</li><li>Is the dependency latency increasing?</li><li>Are requests being rejected before reaching the dependency?</li><li>Is the limiter recovering too slowly?</li></ul><p>A particularly useful dashboard plots three signals together:</p><ol><li>Current concurrency limit</li><li>In-flight requests</li><li>Dependency latency</li></ol><p>Looking at them independently can hide the relationship between capacity and latency.</p><p>Avoid high-cardinality labels such as raw URLs, user IDs, and request IDs.</p><h3>11. When Should You Use Adaptive Concurrency?</h3><p>Adaptive concurrency is not necessary for every service.</p><p>A fixed limit is often the better choice when:</p><ul><li>Dependency capacity is stable and well understood.</li><li>Traffic patterns are predictable.</li><li>The service has low request volume.</li><li>The cost of an adaptive control loop is not justified.</li><li>A simple semaphore already provides sufficient protection.</li></ul><p>Adaptive concurrency becomes more attractive when:</p><ul><li>Dependency capacity changes frequently.</li><li>Traffic is bursty or unpredictable.</li><li>Services scale horizontally.</li><li>Latency is a reliable indicator of queueing.</li><li>Manual concurrency tuning is difficult.</li><li>You need to protect dependencies before they start rejecting requests.</li></ul><p>Start with a fixed limit if it is sufficient. Introduce adaptive behavior when operational evidence shows that static configuration is becoming a limitation.</p><h3>Conclusion</h3><p>A fixed concurrency limit assumes that system capacity is relatively predictable.</p><p>In distributed systems, that assumption does not always hold.</p><p>Dependencies slow down, traffic changes, instances scale, and resource availability evolves.</p><p>Adaptive concurrency provides a feedback loop that adjusts the amount of work allowed in flight based on observed latency and failure signals.</p><p>In Go, the core mechanism is straightforward: track active requests, collect latency samples, estimate congestion, and adjust the concurrency ceiling.</p><p>The difficult part is not implementing a semaphore.</p><p>It is building a stable controller that reacts to real overload without constantly oscillating or making incorrect decisions from noisy measurements.</p><p><strong>Don’t just limit how much work your service can handle. Let it learn when it should slow down.</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b5c364c712b4" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A2A (Agent2Agent Protocol): Building Interoperable Multi-Agent Systems]]></title>
            <link>https://medium.com/@serifcolakel/a2a-agent2agent-protocol-building-interoperable-multi-agent-systems-ff763385e2a6?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/ff763385e2a6</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 18:53:29 GMT</pubDate>
            <atom:updated>2026-10-30T18:53:29.904Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HH4eRJQf5loWqTCJlZPxHA.png" /></figure><p><em>Understanding how AI agents discover, communicate, delegate tasks, and collaborate across different frameworks and platforms.</em></p><h3>1. What Is A2A, and What Problem Does It Solve?</h3><p>As AI applications become more capable, we are moving beyond simple interactions between a user and a single AI model.</p><p>Today, an AI agent can search documentation, query databases, interact with APIs, analyze code, and execute multi-step workflows. However, as these capabilities grow, a new architectural question emerges:</p><p><strong>How can independently developed AI agents communicate and work together?</strong></p><p>Consider an application that needs to analyze a GitLab merge request. The task may involve several different responsibilities:</p><ul><li>A security agent reviews potential vulnerabilities.</li><li>A testing agent analyzes test coverage and identifies missing cases.</li><li>An architecture agent evaluates design decisions and code structure.</li><li>A review agent combines the findings and prepares a final report.</li></ul><p>Each agent might use a different framework, model provider, toolset, or programming language.</p><p>Without a standardized communication mechanism, integrating these agents usually requires custom APIs, shared data contracts, and application-specific orchestration logic.</p><p>This is where <strong>Agent2Agent (A2A) Protocol</strong> becomes relevant.</p><p>A2A is an open protocol designed to enable communication and collaboration between independent AI agent systems. It provides a common interaction model regardless of the framework, programming language, or vendor used to build an agent.</p><p>The protocol was originally developed by Google and contributed to the Linux Foundation. It is designed around interoperability without requiring agents to expose their internal reasoning, memory, tools, or implementation details.</p><p>The important distinction is that A2A does not define how an agent thinks or performs its internal tasks. It defines how one agent can communicate with another.</p><p>In other words, an agent can be implemented using LangGraph, Google ADK, CrewAI, Semantic Kernel, or a completely custom solution and still communicate with another compatible agent through A2A.</p><p><strong>Reference:</strong> <a href="https://a2a-protocol.org/latest/">A2A Protocol — Official Documentation</a></p><h3>2. Why Do We Need A2A?</h3><p>Most agent frameworks provide mechanisms for calling tools, delegating work, or coordinating multiple agents.</p><p>However, these mechanisms are generally designed around the framework’s own execution model.</p><p>For example, an agent built with one framework may have its own task execution logic, state management, and internal tool definitions. Connecting it directly to an agent developed using another framework can require custom integration work.</p><p>A2A addresses the interoperability problem.</p><p>Instead of requiring agents to understand each other’s internal implementations, they communicate through a standardized protocol.</p><p>This gives us several important capabilities:</p><ul><li>Discover agents and understand their capabilities.</li><li>Delegate tasks to remote agents.</li><li>Exchange messages and structured data.</li><li>Track the lifecycle of long-running tasks.</li><li>Receive progress updates through streaming.</li><li>Exchange files and generated artifacts.</li><li>Support authentication and authorization across agent boundaries.</li></ul><p>A2A is particularly useful when agents belong to different teams, applications, organizations, or technology stacks.</p><p>The goal is not to turn every agent into the same implementation. The goal is to allow different implementations to collaborate.</p><h3>3. A2A vs. MCP vs. ACP</h3><p>These three protocols are often discussed together, but they solve different architectural problems.</p><p>TechnologyMain responsibilityMCPAgent ↔ Tools and external contextACPCoding Agent ↔ Client ApplicationA2AAgent ↔ Agent</p><p>Let’s consider a practical example.</p><p>Imagine a research agent that needs to investigate a topic, retrieve information from internal documentation, and delegate a part of its work to a specialized analysis agent.</p><p>In this scenario:</p><ol><li>MCP provides access to documentation, APIs, databases, and other tools.</li><li>A2A allows the research agent to communicate with the analysis agent.</li><li>ACP could be used separately when integrating a coding agent with an editor or another client application.</li></ol><p>A simplified architecture:</p><pre>                    User<br>                     |<br>                     v<br>              Main AI Application<br>                     |<br>                     v<br>               Coordinator Agent<br>                 /           \<br>                /             \<br>             A2A               A2A<br>              /                 \<br>             v                   v<br>      Research Agent       Analysis Agent<br>             |                   |<br>            MCP                 MCP<br>             |                   |<br>       Internal APIs        Database / APIs<br>             |                   |<br>             v                   v<br>          Results             Results<br>                \             /<br>                 \           /<br>                  Coordinator<br>                       |<br>                       v<br>                 Final Response</pre><p>Each agent can use its own tools and internal architecture. A2A provides the communication layer between them.</p><p>One important point: A2A is not a replacement for an agent framework or an internal sub-agent mechanism. It focuses on communication between independent agent applications. Internal orchestration can still be handled by the framework that runs the agent.</p><p><strong>References:</strong> <a href="https://a2a-protocol.org/latest/">A2A and MCP</a> · <a href="https://modelcontextprotocol.io/">MCP Documentation</a> · <a href="https://agentclientprotocol.com/">ACP Documentation</a></p><h3>4. How Does A2A Work?</h3><p>To understand A2A, we need to understand a few core concepts.</p><h3>4.1. Agent Card: Discovering Other Agents</h3><p>One of the most important concepts in A2A is the Agent Card.</p><p>An Agent Card is a JSON document that describes an agent and its capabilities.</p><p>It acts as a machine-readable description of what an agent can do and how another agent can communicate with it.</p><p>An Agent Card can contain:</p><ul><li>Agent name and description</li><li>Service endpoint</li><li>Supported protocol versions and transports</li><li>Available capabilities</li><li>Supported input and output formats</li><li>Skills and example tasks</li><li>Authentication requirements</li></ul><p>By default, an A2A server exposes its Agent Card at:</p><pre>https://agent.example.com/.well-known/agent-card.json</pre><p>For example, a research agent might expose the following description:</p><pre>{<br>  &quot;name&quot;: &quot;Research Agent&quot;,<br>  &quot;description&quot;: &quot;An agent specialized in research and source analysis.&quot;,<br>  &quot;version&quot;: &quot;1.0.0&quot;,<br>  &quot;supportedInterfaces&quot;: [<br>    {<br>      &quot;url&quot;: &quot;https://research.example.com/a2a&quot;,<br>      &quot;protocolBinding&quot;: &quot;JSONRPC&quot;,<br>      &quot;protocolVersion&quot;: &quot;1.0&quot;<br>    }<br>  ],<br>  &quot;capabilities&quot;: {<br>    &quot;streaming&quot;: true,<br>    &quot;pushNotifications&quot;: true,<br>    &quot;extensions&quot;: []<br>  },<br>  &quot;defaultInputModes&quot;: [<br>    &quot;text/plain&quot;<br>  ],<br>  &quot;defaultOutputModes&quot;: [<br>    &quot;text/plain&quot;,<br>    &quot;application/json&quot;<br>  ],<br>  &quot;skills&quot;: [<br>    {<br>      &quot;id&quot;: &quot;market-research&quot;,<br>      &quot;name&quot;: &quot;Market Research&quot;,<br>      &quot;description&quot;: &quot;Researches topics and produces structured findings.&quot;,<br>      &quot;tags&quot;: [<br>        &quot;research&quot;,<br>        &quot;analysis&quot;<br>      ],<br>      &quot;examples&quot;: [<br>        &quot;Research the latest developments in AI agents.&quot;<br>      ],<br>      &quot;inputModes&quot;: [<br>        &quot;text/plain&quot;<br>      ],<br>      &quot;outputModes&quot;: [<br>        &quot;text/plain&quot;,<br>        &quot;application/json&quot;<br>      ]<br>    }<br>  ]<br>}</pre><p>This is a simplified public Agent Card example. Authentication and additional security requirements can also be described through the protocol’s security fields.</p><p>A client can retrieve this document and understand whether the remote agent is suitable for a particular task.</p><p>For example, a coordinator agent looking for an agent capable of analyzing financial documents can inspect registered Agent Cards and evaluate their declared skills.</p><p>Agent discovery can happen through:</p><ul><li>Well-known URLs</li><li>Private or public agent registries</li><li>Directly configured Agent Card URLs</li></ul><p>An Agent Card describes an agent’s advertised capabilities; it does not prove that the agent can successfully perform every advertised task. Capability selection should be combined with authorization, availability checks, and runtime evaluation.</p><p><strong>Reference:</strong> <a href="https://a2a-protocol.org/latest/topics/agent-discovery/">A2A — Agent Discovery</a></p><h3>4.2. Messages and Tasks</h3><p>A2A distinguishes between a message and a task.</p><p><strong>Message</strong></p><p>A message represents communication between a client and an agent. It can contain instructions, clarification requests, or additional information.</p><p><strong>Task</strong></p><p>A task represents a stateful unit of work with its own lifecycle.</p><p>For example, when an agent receives a request to analyze a large document, it may need several minutes to complete the work. Instead of treating the interaction as a simple request-response operation, A2A allows the agent to create a task and report its progress.</p><p>A task has a lifecycle that can include:</p><p>StateMeaningSubmittedTask has been acceptedWorkingAgent is processing the taskInput RequiredAdditional information is neededAuth RequiredAdditional authentication is requiredCompletedTask finished successfullyFailedTask execution failedCanceledTask was canceledRejectedAgent declined the task</p><p>Some states represent interruptions that can be resolved through further interaction, while others represent terminal states.</p><p>This is important for long-running workflows because the client does not need to keep a single request open until every operation is complete.</p><p>A2A also provides two useful identifiers:</p><ul><li>taskId: Identifies an individual task.</li><li>contextId: Groups related messages and tasks within a broader interaction context.</li></ul><p>The task identifier is generated by the agent server. The context identifier helps maintain continuity across multiple related interactions.</p><h3>4.3. Artifacts: Sharing Task Results</h3><p>A2A also introduces the concept of Artifacts.</p><p>An artifact represents the output produced by an agent while completing a task.</p><p>For example:</p><ul><li>A research agent can produce a research report.</li><li>A coding agent can return a generated file.</li><li>An analysis agent can return structured JSON.</li><li>A document processing agent can return an extracted document.</li></ul><p>An artifact can contain multiple parts, including text, binary content, URLs, or structured data.</p><p>This distinction is important:</p><p>Messages are primarily used for communication and interaction, while artifacts represent the actual outputs of a task.</p><p>It allows agents to exchange more than just text-based responses.</p><h3>4.4. Streaming and Asynchronous Communication</h3><p>Not every agent task finishes immediately.</p><p>A research process, document analysis, or code review can take a considerable amount of time.</p><p>A2A supports multiple ways of receiving task updates:</p><p><strong>Streaming</strong></p><p>The client can subscribe to task updates and receive events while the agent is working.</p><p>Depending on the protocol binding, streaming is supported through Server-Sent Events (SSE) or gRPC streaming.</p><p><strong>Polling</strong></p><p>The client can periodically request the current task state.</p><p><strong>Push Notifications</strong></p><p>For long-running tasks where maintaining a persistent connection is not practical, the agent can send updates to a client-provided webhook.</p><p>These mechanisms allow applications to support different execution patterns without inventing custom communication protocols for each agent.</p><p>A2A also defines operations for sending messages, retrieving tasks, canceling tasks, subscribing to updates, and managing push notification configurations.</p><p><strong>References:</strong> <a href="https://a2a-protocol.org/latest/specification/">A2A Specification</a> · <a href="https://a2a-protocol.org/latest/topics/life-of-a-task/">Task Lifecycle</a> · <a href="https://a2a-protocol.org/latest/topics/streaming-and-async/">Streaming and Asynchronous Operations</a></p><h3>5. How to Integrate A2A?</h3><p>A2A provides official SDKs for several programming languages, including:</p><ul><li>Python</li><li>JavaScript / TypeScript</li><li>Java</li><li>Go</li><li>C# / .NET</li><li>Rust</li></ul><p>For TypeScript applications, the official package is @a2a-js/sdk.</p><p>The current SDK implements the A2A v1.0 specification and supports client and server development.</p><h3>Step 1: Install the package</h3><pre>pnpm add @a2a-js/sdk</pre><p>The official JavaScript SDK requires Node.js 20 or later.</p><p>For this example, we will implement a client that communicates with an existing remote research agent.</p><h3>Step 2: Connect to an A2A agent</h3><p>The SDK provides ClientFactory to create a client.</p><pre>import { ClientFactory } from &#39;@a2a-js/sdk/client&#39;;<br><br>const factory = new ClientFactory();<br><br>const client = await factory.createFromUrl(<br>  &#39;https://research-agent.example.com&#39;<br>);</pre><p>The SDK retrieves the Agent Card and selects a compatible transport based on the agent’s advertised interfaces and the client’s supported transports.</p><p>This means we do not need to hardcode every protocol detail into our application.</p><h3>Step 3: Send a message to the agent</h3><p>Once the connection is established, we can send a task request.</p><pre>import {<br>  Role,<br>  type Message,<br>  type SendMessageRequest,<br>} from &#39;@a2a-js/sdk&#39;;<br><br>const message: Message = {<br>  messageId: crypto.randomUUID(),<br>  role: Role.ROLE_USER,<br>  parts: [<br>    {<br>      content: {<br>        $case: &#39;text&#39;,<br>        value: &#39;Research the latest developments in multi-agent systems.&#39;,<br>      },<br>      mediaType: &#39;text/plain&#39;,<br>      filename: &#39;&#39;,<br>      metadata: undefined,<br>    },<br>  ],<br>  taskId: &#39;&#39;,<br>  contextId: &#39;&#39;,<br>  extensions: [],<br>  metadata: {},<br>  referenceTaskIds: [],<br>};<br><br>const request: SendMessageRequest = {<br>  tenant: &#39;&#39;,<br>  message,<br>  configuration: undefined,<br>  metadata: {},<br>};<br><br>const result = await client.sendMessage(request);<br><br>console.log(result);</pre><p>The example uses the SDK’s generated TypeScript message types. Depending on the agent’s behavior, the response can contain a message or a task.</p><p>For production integrations, authentication and request timeouts should be configured as well.</p><p>For example:</p><pre>const result = await client.sendMessage(request, {<br>  serviceParameters: {<br>    Authorization: `Bearer ${process.env.A2A_TOKEN}`,<br>  },<br>  signal: AbortSignal.timeout(60_000),<br>});</pre><p>The client can now communicate with a remote agent without needing to know its internal framework or model implementation.</p><h3>Step 4: Stream task updates</h3><p>For long-running tasks, we can use sendMessageStream.</p><pre>const stream = client.sendMessageStream(request);<br><br>for await (const event of stream) {<br>  // Handle task events:<br>  // - task<br>  // - statusUpdate<br>  // - artifactUpdate<br>  console.log(event);<br>}</pre><p>The actual event union should be handled using the SDK’s generated types.</p><p>This allows us to display task progress, process generated artifacts, and update the application as the remote agent works.</p><p>The SDK also supports task retrieval, cancellation, and push notification configuration.</p><h3>Step 5: Build your own A2A agent</h3><p>A2A is not limited to consuming existing agents. We can also expose our own agent through the protocol.</p><p>The official JavaScript SDK separates server-side responsibilities into three main components:</p><p><strong>AgentExecutor</strong></p><p>Contains the agent’s business logic. It receives the request context, executes the task, and publishes messages, status updates, and artifacts.</p><p><strong>DefaultRequestHandler</strong></p><p>Handles protocol-level responsibilities such as message routing, task storage, cancellation, and push notifications.</p><p><strong>Transport Adapters</strong></p><p>Expose the agent through supported communication protocols.</p><p>The SDK provides adapters for JSON-RPC and HTTP+JSON/REST through Express, as well as gRPC for Node.js.</p><p>The architecture looks like this:</p><pre>Incoming A2A Request<br>        |<br>        v<br> Transport Adapter<br>        |<br>        v<br>DefaultRequestHandler<br>        |<br>        +----&gt; Task Store<br>        |<br>        +----&gt; Execution Event Bus<br>        |<br>        v<br>   AgentExecutor<br>        |<br>        v<br>   Agent Logic<br>        |<br>        +----&gt; LLM<br>        +----&gt; MCP Tools<br>        +----&gt; Internal APIs<br>        +----&gt; Business Logic<br>        |<br>        v<br> Messages / Status / Artifacts<br>        |<br>        v<br>   A2A Response</pre><p>The SDK also supports custom task stores and event buses, which allows us to integrate persistent databases, caches, or message brokers instead of relying exclusively on in-memory implementations.</p><p>For production, this separation is particularly useful because protocol handling, task execution, and persistence can evolve independently.</p><p><strong>Reference:</strong> <a href="https://github.com/a2aproject/a2a-js">Official A2A JavaScript SDK</a></p><h3>6. Integrating A2A with the Vercel AI SDK</h3><p>There is another interesting integration worth looking at.</p><p>The Vercel AI SDK lists an A2A community provider called a2a-ai-provider.</p><p>This package allows an A2A-compatible agent to be consumed through familiar AI SDK functions such as generateText and streamText.</p><p>This is conceptually similar to the ACP provider we discussed previously.</p><p>Instead of implementing every A2A interaction manually, we can use the community provider as an adapter between the A2A protocol and the AI SDK.</p><h3>Installation</h3><pre>pnpm add ai a2a-ai-provider</pre><h3>Provider configuration</h3><pre>import { a2a } from &#39;a2a-ai-provider&#39;;<br>import { generateText } from &#39;ai&#39;;<br><br>const result = await generateText({<br>  model: a2a(<br>    &#39;https://research-agent.example.com/.well-known/agent-card.json&#39;<br>  ),<br>  prompt: &#39;Research the latest developments in agentic AI.&#39;,<br>});<br><br>console.log(result.text);</pre><h3>Streaming responses</h3><p>We can also use streamText:</p><pre>import { a2a } from &#39;a2a-ai-provider&#39;;<br>import { streamText } from &#39;ai&#39;;<br><br>const result = streamText({<br>  model: a2a(<br>    &#39;https://research-agent.example.com/.well-known/agent-card.json&#39;<br>  ),<br>  prompt: &#39;Analyze the current multi-agent ecosystem.&#39;,<br>  providerOptions: {<br>    a2a: {<br>      contextId: crypto.randomUUID(),<br>    },<br>  },<br>});<br><br>await result.consumeStream();<br><br>console.log(await result.content);</pre><p>Here, contextId is used to associate related interactions with the same context.</p><p>This integration makes it easier to use A2A agents in existing AI SDK applications.</p><p>However, there is an important distinction:</p><p>The official A2A SDK is the protocol implementation, whereas a2a-ai-provider is a community-maintained adapter.</p><p>The community provider’s documentation describes it as under active development, and its GitHub repository currently labels it alpha and advises against production workloads.</p><p>For that reason, I would use it for experimentation and evaluate the official SDK directly for production-critical integrations until the provider’s maturity and compatibility are established.</p><p><strong>References:</strong> <a href="https://ai-sdk.dev/providers/community-providers/a2a">AI SDK — A2A Community Provider</a> · <a href="https://github.com/dracoblue/a2a-ai-provider">a2a-ai-provider GitHub</a></p><h3>7. Supported Communication Protocols</h3><p>A2A separates its common data model and operations from the transport used to communicate.</p><p>The current specification defines three standard protocol bindings:</p><p>ProtocolDescriptionJSON-RPCJSON-based remote procedure callsHTTP+JSON / RESTResource-oriented HTTP communicationgRPCRPC communication using Protocol Buffers and gRPC</p><p>An agent can support more than one binding.</p><p>For example, a service could expose JSON-RPC and REST interfaces using the same underlying task execution logic.</p><p>This separation is valuable because clients and servers can select a compatible transport without changing the fundamental meaning of tasks, messages, and artifacts.</p><p>The official JavaScript SDK supports all three transports, with its gRPC integration requiring a Node.js environment and additional dependencies.</p><p>A2A also defines protocol versioning. Clients and agents use the A2A-Version header to identify the version they are communicating with.</p><p>The current v1.0 specification includes changes from the earlier v0.3 interface, so compatibility and migration should be considered when connecting existing implementations.</p><p><strong>Reference:</strong> <a href="https://a2a-protocol.org/v1.0.0/specification/">A2A Protocol Specification</a></p><h3>8. Advantages of A2A</h3><h3>Interoperability across frameworks</h3><p>One of A2A’s biggest advantages is the ability to connect agents built using different frameworks and programming languages.</p><p>We do not have to standardize every team’s internal agent implementation just to enable collaboration.</p><p>A Python-based research agent can communicate with a TypeScript-based coordinator, while another agent may be implemented using Go or .NET.</p><p>This reduces framework-level coupling.</p><h3>Independent agent implementations</h3><p>Agents do not need to expose their internal prompts, model configurations, memory, or tools.</p><p>They only need to expose their supported capabilities and implement the agreed communication contract.</p><p>This allows teams to evolve their internal implementations without forcing all consumers to change their integration code.</p><h3>Better support for long-running workflows</h3><p>A2A is designed around stateful tasks.</p><p>Instead of implementing custom job tracking, status polling, event streaming, and result retrieval for every remote agent, we can use standardized operations.</p><p>This is particularly useful for research, document processing, code analysis, and other workflows that may take several minutes or longer.</p><h3>More flexible output handling</h3><p>Agents can exchange text, files, structured data, and other supported content types through message parts and artifacts.</p><p>This makes the protocol useful beyond conventional text-based chat applications.</p><h3>Centralized agent discovery and integration</h3><p>Agent Cards allow applications to discover what an agent offers and how to communicate with it.</p><p>Combined with a curated internal agent registry, this can provide a common discovery layer for enterprise AI platforms.</p><h3>9. Limitations and Trade-offs</h3><p>A2A solves a real interoperability problem, but it also introduces architectural complexity.</p><h3>It does not provide orchestration by itself</h3><p>A2A defines how agents communicate, not how an entire multi-agent system should be planned and executed.</p><p>It does not decide which agent should receive a task, how many agents should participate, or how conflicting results should be resolved.</p><p>Those responsibilities remain with an orchestrator or an agent framework.</p><p>For example, LangGraph, Google ADK, or a custom coordinator can decide when to delegate a task through A2A.</p><h3>Distributed execution introduces more failure scenarios</h3><p>When agents communicate across network boundaries, new failure scenarios appear:</p><ul><li>A remote agent may become unavailable.</li><li>A request may time out while the task continues running.</li><li>A task may fail after partial execution.</li><li>A client may disconnect before receiving the final artifact.</li><li>A downstream agent may return an incomplete or unexpected result.</li></ul><p>Therefore, retries, cancellation, idempotency, timeout policies, and persistent task storage need to be considered carefully.</p><h3>More communication can increase latency and cost</h3><p>Every agent handoff introduces additional work.</p><p>A complex multi-agent workflow may involve several model calls, network requests, and tool executions.</p><p>Using more agents does not automatically improve the result.</p><p>We should introduce agent delegation only when the additional specialization or parallelism justifies the extra latency, cost, and operational complexity.</p><h3>Agent compatibility does not guarantee equivalent capabilities</h3><p>Two agents may both implement A2A but provide very different skills and execution behavior.</p><p>A2A standardizes communication, not the quality or accuracy of an agent.</p><p>Clients still need to evaluate supported capabilities, input/output formats, and the reliability of the remote service.</p><h3>Version compatibility requires attention</h3><p>The transition from earlier protocol versions to v1.0 introduced changes to operation names, Agent Cards, and other protocol details.</p><p>The official TypeScript SDK provides an opt-in v0.3 compatibility layer, which can help during staged migration.</p><p>However, version compatibility should be tested explicitly rather than assumed.</p><h3>Security does not come automatically</h3><p>A2A supports authentication and authorization mechanisms, but the protocol does not automatically make remote agents trustworthy.</p><p>The application must decide which agents can communicate, what data can be shared, and which actions are permitted.</p><p><strong>Reference:</strong> <a href="https://a2a-protocol.org/v1.0.0/specification/">A2A v1.0 Specification</a> · <a href="https://a2a-protocol.org/latest/specification/">A2A Security Considerations</a></p><h3>10. Security and Production Considerations</h3><p>This is one of the most important parts of adopting A2A in an enterprise environment.</p><p>An agent may receive data from another agent, process documents, invoke tools, and return results that influence subsequent decisions.</p><p>We should not treat every remote agent as trusted simply because it implements the same protocol.</p><p>Some important considerations:</p><h3>Authentication</h3><p>A2A relies on established authentication mechanisms such as HTTP authentication, OAuth 2.0, OpenID Connect, API keys, and mutual TLS where appropriate.</p><p>The agent can advertise supported authentication schemes through its Agent Card.</p><p>Credentials are handled at the transport layer, not embedded directly into protocol messages.</p><h3>Authorization</h3><p>Authentication and authorization are different responsibilities.</p><p>Knowing who is calling an agent does not automatically mean that the caller should be allowed to invoke every skill.</p><p>Authorization should consider:</p><ul><li>Which application or user initiated the task?</li><li>Which agent is allowed to communicate with the target agent?</li><li>Which skills can be invoked?</li><li>Which business operations are permitted?</li><li>Which data can be accessed or shared?</li></ul><p>For financial or other sensitive enterprise environments, authorization should be enforced at the skill and business-action levels.</p><h3>Data isolation</h3><p>Agents should receive only the information required to complete their tasks.</p><p>A research agent should not automatically receive the entire conversation history, database access, or credentials of another agent.</p><p>Task and context storage should also respect user and tenant boundaries.</p><h3>Agent Card validation</h3><p>Agent Cards describe service identity and capabilities.</p><p>For sensitive environments, clients should verify the origin of Agent Cards and validate their signatures when available.</p><p>A discovery result should not automatically be treated as a trusted execution target.</p><h3>Observability and auditing</h3><p>A multi-agent system needs visibility beyond individual model calls.</p><p>We should record:</p><ul><li>Caller and target agent identifiers</li><li>Task and context identifiers</li><li>Correlation and trace identifiers</li><li>Task state transitions</li><li>Request duration and failure reasons</li><li>Artifact metadata and access</li><li>Sensitive action approvals</li></ul><p>OpenTelemetry and distributed tracing can help follow a request across multiple agents.</p><h3>Push notification security</h3><p>Push notification webhooks need their own security controls.</p><p>For example, the receiver should authenticate the notification source, validate the task identifier, and process duplicate notifications safely.</p><p>Webhook endpoints should also be protected against SSRF and unauthorized access.</p><p>These controls are application and infrastructure responsibilities that must be designed alongside the protocol integration.</p><p><strong>Reference:</strong> <a href="https://a2a-protocol.org/latest/topics/enterprise-ready/">A2A — Enterprise Implementation</a></p><h3>11. What Can an AI Chapter Lead Contribute?</h3><p>I think this is where A2A becomes especially interesting from an engineering leadership perspective.</p><p>The value is not simply adding another protocol to the technology stack. It is establishing a reusable architecture for teams that want to build specialized agents independently and allow them to collaborate.</p><p>There are several areas where we can contribute.</p><h3>1. Establish a common agent communication standard</h3><p>Different teams may build agents using different languages and frameworks.</p><p>Instead of forcing everyone to adopt the same framework, we can define a common communication contract based on A2A.</p><p>This allows teams to own their agent implementations while maintaining a consistent integration approach.</p><h3>2. Build an internal agent registry</h3><p>We can create a centralized catalog of available agents and their capabilities.</p><p>For example:</p><p>AgentResponsibilityAvailable skillsResearch AgentInformation gatheringResearch, source analysisDatabase AgentData analysisSQL analysis, reportingCode Review AgentCode qualityReview, recommendationsSecurity AgentSecurity analysisVulnerability assessmentDocumentation AgentDocumentationSearch, summarization</p><p>Each agent can publish an Agent Card, while the internal registry handles discovery, ownership, versioning, authorization, and availability.</p><p>This would help teams reuse existing capabilities instead of implementing the same functionality repeatedly.</p><h3>3. Define a reusable orchestration architecture</h3><p>A2A should be combined with a clear orchestration strategy.</p><p>We can establish a reference architecture that separates:</p><ul><li>Agent discovery</li><li>Task delegation</li><li>Agent communication</li><li>Execution state management</li><li>Tool and data access</li><li>Result aggregation</li><li>Human approval</li></ul><p>The coordinator should remain responsible for task planning and result aggregation. Specialized agents should focus on their assigned responsibilities.</p><h3>4. Establish security and governance standards</h3><p>A2A introduces communication between independently managed systems.</p><p>We should define clear standards for agent authentication, authorization, data sharing, tenant isolation, and audit logging.</p><p>This is especially important when agents interact with internal systems or execute business operations.</p><h3>5. Run a practical multi-agent pilot</h3><p>Rather than introducing A2A across every team immediately, we can start with a well-defined workflow.</p><p>A good example would be an automated merge request review process.</p><p><strong>Example workflow:</strong></p><ol><li>A developer submits a merge request.</li><li>The Coordinator Agent receives the review request.</li><li>The Security Agent analyzes potential vulnerabilities.</li><li>The Testing Agent evaluates test coverage and identifies missing cases.</li><li>The Architecture Agent reviews design and implementation decisions.</li><li>Each specialist returns findings through A2A.</li><li>The Coordinator combines the results into a structured review report.</li><li>A developer reviews the recommendations before any changes are merged.</li></ol><p>Each agent can use its own MCP servers to access GitLab, documentation, test tools, or other required services.</p><p>This allows us to evaluate the actual value of agent specialization and interoperability.</p><h3>6. Measure the results</h3><p>A successful pilot should be evaluated using real measurements.</p><p>Some useful metrics:</p><ul><li>Task completion rate</li><li>Agent-to-agent communication latency</li><li>Overall workflow duration</li><li>Failed or timed-out tasks</li><li>Duplicate execution rate</li><li>Token and infrastructure costs</li><li>Human intervention rate</li><li>Quality of the final aggregated result</li></ul><p>The objective is to determine whether distributing work across specialized agents produces better results than a single-agent implementation.</p><h3>7. Create reusable learning resources</h3><p>A practical reference implementation, architecture documentation, and internal workshops can help developers understand when A2A is useful.</p><p>Teams should learn not only how to implement the protocol, but also when not to use it.</p><p>Not every internal function needs to become a remote agent. For simple operations within the same runtime, a regular function or framework-native tool call may be more appropriate.</p><h3>12. When Should We Use A2A?</h3><p>A2A is most useful when communication between independent agents is an actual architectural requirement.</p><p>ScenarioRecommendationAgents built with different frameworks need to collaborateA2ADifferent teams expose independently maintained agentsA2ARemote, long-running agent tasks need trackingA2AAgents need to exchange structured outputs and filesA2AAn agent needs access to APIs and databasesMCPA single agent needs internal tool executionNative tools or MCPMultiple agents operate within one tightly coupled workflowFramework-native orchestration may be sufficient</p><p>The important thing is to avoid introducing network boundaries and protocol complexity when a simpler implementation would work.</p><p>A2A should solve an interoperability requirement, not become an additional abstraction by default.</p><h3>Conclusion</h3><p>A2A addresses an important challenge in the evolution of agentic AI systems: enabling independently developed agents to communicate and collaborate.</p><p>As AI applications become more specialized, a single agent will not necessarily be responsible for every task. Different agents may own different capabilities, use different frameworks, and operate in different environments.</p><p>A2A provides a standardized way for these systems to discover each other, delegate tasks, exchange information, and track execution.</p><p>Combined with MCP, it creates a useful architectural separation: MCP connects agents to tools and external context, while A2A connects agents to other agents.</p><p>For an AI Chapter Lead, the opportunity is to establish common integration standards, build an internal agent ecosystem, define security boundaries, and help teams develop reusable capabilities.</p><p><strong>My recommendation:</strong> Start with one practical multi-agent workflow, implement it using the official A2A SDK, and evaluate the operational benefits against a conventional single-agent or framework-native orchestration approach.</p><p>The real question is not how many agents we can connect. It is whether connecting specialized agents makes the system more reliable, maintainable, and effective.</p><h3>References and Further Reading</h3><ol><li><a href="https://a2a-protocol.org/latest/">A2A Protocol — Official Website</a> — Overview, architecture, ecosystem, and documentation.</li><li><a href="https://a2a-protocol.org/v1.0.0/specification/">A2A Protocol — Specification v1.0</a> — Protocol operations, data models, task lifecycle, transports, and security.</li><li><a href="https://a2a-protocol.org/latest/topics/agent-discovery/">A2A — Agent Discovery</a> — Agent Cards, discovery mechanisms, and capability descriptions.</li><li><a href="https://a2a-protocol.org/latest/topics/life-of-a-task/">A2A — Life of a Task</a> — Task lifecycle and interaction patterns.</li><li><a href="https://a2a-protocol.org/latest/topics/streaming-and-async/">A2A — Streaming and Asynchronous Operations</a> — Streaming, task updates, and push notifications.</li><li><a href="https://a2a-protocol.org/latest/topics/enterprise-ready/">A2A — Enterprise Implementation</a> — Authentication, authorization, observability, and enterprise security.</li><li><a href="https://github.com/a2aproject/a2a-js">A2A — Official JavaScript SDK</a> — TypeScript client/server implementation, transport adapters, and runnable examples.</li><li><a href="https://github.com/a2aproject">A2A — Official GitHub Organization</a> — SDKs, samples, and project development.</li><li><a href="https://ai-sdk.dev/providers/community-providers/a2a">AI SDK — A2A Community Provider</a> — Integrating A2A agents with Vercel AI SDK.</li><li><a href="https://github.com/dracoblue/a2a-ai-provider">A2A AI Provider</a> — Community provider implementation and current maturity status.</li><li><a href="https://modelcontextprotocol.io/">Model Context Protocol</a> — Complementary agent-to-tool integration protocol.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff763385e2a6" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Retry Budgets in Go: Preventing Retries from Becoming an Outage]]></title>
            <link>https://medium.com/@serifcolakel/retry-budgets-in-go-preventing-retries-from-becoming-an-outage-b71fdd606a4e?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/b71fdd606a4e</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[backend]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 18:38:45 GMT</pubDate>
            <atom:updated>2026-10-30T18:38:45.739Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kjx7uFi6nCeRPBlGuv6Zeg.png" /><figcaption>Retry Budgets in Go: Preventing Retries from Becoming an Outage Image</figcaption></figure><p>Retries are one of the most common ways to make distributed systems more resilient.</p><p>A request fails because of a temporary network issue. The client waits briefly and tries again. The second attempt succeeds, and the failure never reaches the user.</p><p>That sounds reasonable.</p><p>But what happens when the dependency is already overloaded?</p><p>Requests start failing. Every caller retries. Those retries create additional traffic, which causes more failures, which trigger even more retries.</p><p>A temporary slowdown can turn into a retry storm.</p><p>Retries are not free. Every attempt consumes resources, occupies connections, increases queue pressure, and adds work to an already struggling dependency.</p><p>This is where <strong>retry budgets</strong> become useful.</p><p>A retry budget places a limit on how much additional traffic a service can generate through retries. Instead of allowing every failed request to retry independently, it establishes a shared budget across requests.</p><p>In this article, we’ll explore:</p><ul><li>Why per-request retry limits are not enough</li><li>How retry amplification creates cascading failures</li><li>The difference between retry limits and retry budgets</li><li>How to implement a concurrency-safe retry budget in Go</li><li>How to combine budgets with exponential backoff and jitter</li><li>How retries interact with timeouts, bulkheads, and circuit breakers</li><li>What to measure and test before using retries in production</li></ul><h3>1. The Problem: Retries Can Amplify Failures</h3><p>Imagine a service that sends requests to an external market data provider.</p><p>Under normal conditions:</p><ul><li>The service receives 1,000 requests per second.</li><li>Each request makes one provider call.</li><li>The provider responds successfully.</li></ul><p>Now the provider starts experiencing problems. Its error rate increases, and requests become slower.</p><p>The service retries failed requests up to three times.</p><p>If every original request fails, the provider may receive:</p><p>AttemptRequestsOriginal requests1,000First retries1,000Second retries1,000Third retries1,000<strong>Total4,000</strong></p><p>The service has turned 1,000 incoming requests into as many as 4,000 downstream attempts.</p><p>This is retry amplification.</p><p>The actual situation can be worse. Retries may overlap with new incoming traffic, other services may retry independently, and clients may have their own retry policies.</p><p>The dependency is already struggling, but the system keeps sending it more work.</p><p>&lt;figure&gt; &lt;img src=”https://dummyimage.com/1200x520/101827/00eaff.png&amp;text=Retry+Amplification%3A+1000+Requests+%E2%86%92+4000+Attempts&quot; alt=”Retry amplification diagram” /&gt; &lt;/figure&gt;</p><p>The important distinction is that retries can improve the success rate of individual requests while making the overall system less stable.</p><p>A retry policy should not only ask:</p><blockquote><em>How many times should this request retry?</em></blockquote><p>It should also ask:</p><blockquote><em>How much additional traffic can this service afford to generate?</em></blockquote><h3>2. Why Max Retries Is Not Enough</h3><p>A typical retry implementation has a maximum attempt count:</p><pre>for attempt := 1; attempt &lt;= 3; attempt++ {<br>    err := callDependency()<br>    if err == nil {<br>        return nil<br>    }<br>}</pre><p>This limits retries for an individual request.</p><p>But it does not limit retries across the entire service.</p><p>Consider a service receiving 10,000 requests per second. Even with only one retry per request, it can generate another 10,000 downstream attempts per second during an outage.</p><p>A per-request retry limit provides a local constraint, not a global traffic constraint.</p><p>There are two separate controls:</p><p>ControlWhat it limitsMaximum attemptsRetries for one requestRetry budgetRetries across a service or dependency</p><p>You generally want both.</p><p>A maximum attempt count prevents one request from retrying indefinitely. A shared retry budget prevents thousands of requests from independently exhausting a dependency.</p><h3>3. What Is a Retry Budget?</h3><p>A retry budget is a shared allowance for additional attempts.</p><p>Instead of allowing every failed request to retry automatically, the service must acquire permission from the budget before performing another attempt.</p><p>One practical approach is a token bucket.</p><p>The model is simple:</p><ol><li>Successful initial requests add tokens to the budget.</li><li>Each retry consumes one token.</li><li>The budget has a maximum capacity.</li><li>When no tokens are available, additional retries are rejected.</li></ol><p>For example, suppose a service uses these settings:</p><ul><li>Maximum budget: 20 tokens</li><li>Credit per successful initial request: 0.1 tokens</li><li>Cost per retry: 1 token</li></ul><p>Every ten successful initial requests generate one retry token.</p><p>If the service experiences a sudden failure, it can spend its accumulated tokens on retries. Once the budget is exhausted, it stops generating additional retry traffic until successful initial requests replenish it.</p><p>This creates an important feedback mechanism:</p><ul><li>Healthy service: successful requests replenish the budget.</li><li>Unhealthy service: fewer successful requests mean less budget replenishment.</li><li>Outage: retries consume the remaining budget and eventually stop.</li></ul><p>The budget is not a guarantee that a dependency can handle a particular amount of traffic. It is a mechanism for limiting retry amplification.</p><h3>4. Implementing a Retry Budget in Go</h3><p>Let’s start with the shared budget.</p><p>We need a structure that is safe to access concurrently because multiple requests may attempt to consume or replenish tokens at the same time.</p><pre>package retry<br><br>import &quot;sync&quot;<br><br>type RetryBudget struct {<br>    mu sync.Mutex<br>    tokens          float64<br>    capacity        float64<br>    creditPerSuccess float64<br>}<br><br>func NewRetryBudget(<br>    capacity float64,<br>    creditPerSuccess float64,<br>) *RetryBudget {<br>    return &amp;RetryBudget{<br>        capacity:         capacity,<br>        creditPerSuccess: creditPerSuccess,<br>    }<br>}<br><br>func (b *RetryBudget) RecordSuccess() {<br>    b.mu.Lock()<br>    defer b.mu.Unlock()<br>    b.tokens += b.creditPerSuccess<br>    if b.tokens &gt; b.capacity {<br>        b.tokens = b.capacity<br>    }<br>}<br><br>func (b *RetryBudget) TryTake() bool {<br>    b.mu.Lock()<br>    defer b.mu.Unlock()<br>    if b.tokens &lt; 1 {<br>        return false<br>    }<br>    b.tokens--<br>    return true<br>}</pre><p>The implementation deliberately keeps the budget small and predictable.</p><p>RecordSuccess adds credits without exceeding the configured capacity.</p><p>TryTake atomically checks and consumes a token. This prevents concurrent requests from spending the same token.</p><p>For example:</p><pre>budget := NewRetryBudget(<br>    20,   // Maximum tokens<br>    0.1,  // Credit per successful initial request<br>)</pre><p>A successful initial request adds 0.1 tokens. A retry requires one complete token.</p><p>The budget starts empty. This is intentional: a newly started instance does not immediately receive permission to generate retries. If a service needs an initial retry allowance, that can be configured explicitly.</p><h3>5. Building a Retry Executor</h3><p>A retry budget is only one part of a retry policy.</p><p>A useful retry executor should also support:</p><ul><li>A maximum number of attempts</li><li>Exponential backoff</li><li>Jitter</li><li>Context cancellation</li><li>Retryable error classification</li><li>A shared retry budget</li></ul><p>First, define the policy:</p><pre>package retry<br><br>import (<br>    &quot;context&quot;<br>    &quot;errors&quot;<br>    &quot;math/rand&quot;<br>    &quot;time&quot;<br>)<br><br>var ErrInvalidPolicy = errors.New(&quot;invalid retry policy&quot;)<br><br>type Policy struct {<br>    MaxAttempts  int<br>    InitialDelay time.Duration<br>    MaxDelay     time.Duration<br>    Budget      *RetryBudget<br>    ShouldRetry func(error) bool<br>}</pre><p>MaxAttempts includes the original attempt.</p><p>For example, MaxAttempts: 3 means one original attempt and at most two retries.</p><p>Next, calculate the exponential backoff.</p><pre>func backoff(<br>    attempt int,<br>    initial time.Duration,<br>    maximum time.Duration,<br>) time.Duration {<br>    delay := initial<br><br>    for i := 1; i &lt; attempt; i++ {<br>        if delay &gt;= maximum/2 {<br>            return maximum<br>        }<br>        delay *= 2<br>    }<br><br>    if delay &gt; maximum {<br>        return maximum<br>    }<br><br>    return delay<br>}</pre><p>The delay grows exponentially until it reaches the configured maximum.</p><p>For example:</p><p>RetryMaximum delay1100ms2200ms3400ms4800ms51.6s</p><p>The next step is jitter.</p><p>Without jitter, clients that fail at approximately the same time may retry at approximately the same time. This creates synchronized traffic spikes.</p><p>Full jitter chooses a random delay between zero and the calculated maximum:</p><pre>func fullJitter(maxDelay time.Duration) time.Duration {<br>    if maxDelay &lt;= 0 {<br>        return 0<br>    }<br><br>    return time.Duration(<br>        rand.Float64() * float64(maxDelay),<br>    )<br>}</pre><p>The package-level math/rand functions are safe for concurrent use. If you need reproducible tests, inject a random source rather than depending on global randomness.</p><p>Now we can implement the retry executor.</p><pre>func Do(<br>    ctx context.Context,<br>    policy Policy,<br>    operation func(context.Context) error,<br>) error {<br>    if policy.MaxAttempts &lt; 1 ||<br>        policy.InitialDelay &lt;= 0 ||<br>        policy.MaxDelay &lt; policy.InitialDelay ||<br>        operation == nil {<br>        return ErrInvalidPolicy<br>    }<br><br>    if policy.ShouldRetry == nil {<br>        return ErrInvalidPolicy<br>    }<br>    for attempt := 1; attempt &lt;= policy.MaxAttempts; attempt++ {<br>        if err := ctx.Err(); err != nil {<br>            return err<br>        }<br>        err := operation(ctx)<br>        if err == nil {<br>            // Only successful initial attempts replenish the budget.<br>            if attempt == 1 &amp;&amp; policy.Budget != nil {<br>                policy.Budget.RecordSuccess()<br>            }<br>            return nil<br>        }<br>        if attempt == policy.MaxAttempts {<br>            return err<br>        }<br>        if !policy.ShouldRetry(err) {<br>            return err<br>        }<br>        // Do not generate more traffic when the shared<br>        // retry budget has been exhausted.<br>        if policy.Budget != nil &amp;&amp; !policy.Budget.TryTake() {<br>            return err<br>        }<br>        maxDelay := backoff(<br>            attempt,<br>            policy.InitialDelay,<br>            policy.MaxDelay,<br>        )<br>        delay := fullJitter(maxDelay)<br>        timer := time.NewTimer(delay)<br>        select {<br>        case &lt;-ctx.Done():<br>            if !timer.Stop() {<br>                &lt;-timer.C<br>            }<br>            return ctx.Err()<br>        case &lt;-timer.C:<br>        }<br>    }<br>    return nil<br>}</pre><p>A few design decisions are worth highlighting.</p><p><strong>First, the budget is consumed before the retry.</strong></p><p>Once the service decides to schedule another attempt, it spends a token. If the context is canceled during the backoff period, that token is not refunded.</p><p>This is a conservative choice. The budget controls permission to generate retry work, not only completed downstream calls.</p><p><strong>Second, only successful initial attempts replenish the budget.</strong></p><p>A successful retry does not replenish it. Otherwise, a service could keep generating retries and effectively fund its own retry traffic during a degraded period.</p><p><strong>Third, the context is checked before each attempt and during backoff.</strong></p><p>A canceled request should not continue generating work.</p><p>The executor returns the last operation error when the retry budget is exhausted. This preserves the underlying failure for callers, although production implementations may also expose a separate retry-denied metric or callback.</p><h3>6. Choosing Which Errors Are Retryable</h3><p>Not every failure should trigger a retry.</p><p>A retry policy should distinguish between transient failures and permanent failures.</p><p>Examples of potentially retryable failures:</p><ul><li>Temporary network errors</li><li>Connection resets</li><li>Selected HTTP 5xx responses</li><li>HTTP 429 responses, subject to server guidance</li><li>Temporary resource unavailability</li></ul><p>Examples of failures that generally should not be retried:</p><ul><li>Invalid input</li><li>Authentication or authorization failures</li><li>Unsupported operations</li><li>Validation errors</li><li>Requests that have already exceeded their deadline</li></ul><p>A simple classifier might look like this:</p><pre>func shouldRetry(err error) bool {<br>    var netErr net.Error<br><br>    if errors.As(err, &amp;netErr) {<br>        return netErr.Timeout() || netErr.Temporary()<br>    }<br>    return false<br>}</pre><p>This is only an illustration. net.Error.Temporary() is deprecated, and real applications should classify errors according to their HTTP client, database driver, RPC framework, and dependency-specific semantics.</p><p>For HTTP clients, for example, a 429 response may include Retry-After. A robust policy should respect that server-provided delay where applicable, while still enforcing the request deadline and retry budget.</p><p>Also remember that retrying a request is not always safe.</p><p>A GET request is generally easier to retry than a payment or order-creation request. For operations with side effects, use idempotency keys or another deduplication mechanism.</p><p>A retry budget controls traffic. It does not make an operation safe to execute twice.</p><h3>7. Putting It Together</h3><p>Let’s use the retry executor with a market data provider.</p><pre>budget := retry.NewRetryBudget(<br>    20,<br>    0.1,<br>)<br><br>policy := retry.Policy{<br>    MaxAttempts:  3,<br>    InitialDelay: 100 * time.Millisecond,<br>    MaxDelay:     2 * time.Second,<br>    Budget:       budget,<br>    ShouldRetry:  shouldRetry,<br>}<br><br>ctx, cancel := context.WithTimeout(<br>    context.Background(),<br>    3*time.Second,<br>)<br><br>defer cancel()<br><br>err := retry.Do(<br>    ctx,<br>    policy,<br>    func(ctx context.Context) error {<br>        return marketDataClient.Fetch(ctx, &quot;AAPL&quot;)<br>    },<br>)<br><br>if err != nil {<br>    // Handle the final operation error.<br>}</pre><p>The behavior is now bounded by multiple independent controls:</p><ul><li>The request can make at most three attempts.</li><li>The request cannot exceed its context deadline.</li><li>Each retry uses exponential backoff with jitter.</li><li>The service must have an available retry token.</li><li>Only successful initial attempts replenish the shared budget.</li></ul><p>This is much safer than allowing every request to retry independently.</p><h3>8. Retry Budgets and Other Resilience Patterns</h3><p>Retry budgets do not replace other resilience mechanisms. They complement them.</p><p>MechanismPrimary responsibilityTimeoutLimits how long an operation can runBackpressureControls how much work enters a systemLoad sheddingRejects work when capacity is insufficientBulkheadIsolates resource consumptionCircuit breakerStops calls to an unhealthy dependencyRetry budgetLimits additional traffic generated by retries</p><p>Consider how these mechanisms work together.</p><p>A request enters the service. Backpressure controls admission. A bulkhead limits concurrent calls to the provider. The request has a deadline. If the provider fails, the retry policy classifies the error and checks the retry budget. If the dependency is persistently unhealthy, the circuit breaker can stop further calls.</p><p>Each mechanism addresses a different failure mode.</p><h3>A subtle interaction: Bulkheads and retries</h3><p>Retry placement matters.</p><p>Suppose a bulkhead permit is acquired once around the entire retry loop. The permit remains occupied during backoff.</p><p>That may be desirable if the goal is to limit the number of complete operations, but it can also waste scarce concurrency capacity while requests are sleeping.</p><p>Alternatively, acquiring a permit for each individual attempt releases capacity during backoff. However, this allows more logical requests to remain in progress simultaneously.</p><p>Neither approach is universally correct. The choice depends on what the bulkhead is protecting:</p><ul><li>If it protects active downstream calls, acquire a permit per attempt.</li><li>If it protects the number of in-flight logical operations, hold the permit across the complete retry operation.</li></ul><p>The important thing is to define the resource boundary explicitly.</p><h3>9. Is a Local Retry Budget Enough?</h3><p>A mutex-protected retry budget is local to one process.</p><p>If you run ten replicas, each replica has its own budget.</p><p>For example:</p><ul><li>10 replicas</li><li>20 tokens per replica</li><li>200 tokens of aggregate retry capacity</li></ul><p>This may be perfectly acceptable. Local budgets are simple, fast, and do not introduce another distributed dependency.</p><p>But if the external provider enforces a strict global quota, local budgets alone may not be sufficient.</p><p>Possible approaches include:</p><ol><li><strong>Local budgets:</strong> Simple and independent, but aggregate capacity grows with replica count.</li><li><strong>Centralized budget:</strong> Coordinates retry allowance across instances, but introduces network calls and another failure boundary.</li><li><strong>Provider-aware limits:</strong> Use the provider’s documented quota and response headers to coordinate traffic.</li><li><strong>Centralized dispatch:</strong> Route downstream work through a shared queue or dispatcher that controls the total attempt rate.</li></ol><p>Do not introduce a distributed retry-budget service automatically. The coordination mechanism can become more expensive and less reliable than the problem it is trying to solve.</p><p>Start with local budgets when they provide sufficient protection. Move to centralized coordination only when the actual quota or failure model requires it.</p><h3>10. Observability: Measure Retry Behavior</h3><p>A retry policy that cannot be observed is difficult to operate.</p><p>At minimum, expose metrics that help answer:</p><ul><li>How often are requests retried?</li><li>How many retries are denied by the budget?</li><li>Which dependencies consume the most retry capacity?</li><li>How often do requests exhaust their attempt limit?</li><li>How much time is spent waiting between attempts?</li></ul><p>Useful metrics include:</p><p>MetricTypePurposeretry_attempts_totalCounterTotal retry attemptsretry_budget_denied_totalCounterRetries rejected by the budgetretry_budget_tokensGaugeCurrent available tokensretry_exhausted_totalCounterRequests reaching the attempt limitretry_delay_secondsHistogramTime spent in retry backoff</p><p>Avoid using raw URLs, request IDs, user IDs, or other high-cardinality values as metric labels.</p><p>Prefer bounded dimensions such as dependency name, operation, and outcome.</p><p>A particularly useful signal is the ratio of retries to original requests.</p><p>If the retry rate starts increasing while the initial success rate drops, the system may be entering a retry-amplification cycle.</p><p>The retry budget should make that cycle bounded, but the metrics help you understand why it happened.</p><h3>11. Testing Retry Budgets</h3><p>Concurrency behavior should be tested explicitly.</p><p>The first test verifies that successful initial requests replenish the budget and that retries consume tokens.</p><pre>func TestRetryBudget(t *testing.T) {<br>    budget := NewRetryBudget(2, 1)<br><br>    budget.RecordSuccess()<br>    budget.RecordSuccess()<br>    if !budget.TryTake() {<br>        t.Fatal(&quot;expected first token&quot;)<br>    }<br>    if !budget.TryTake() {<br>        t.Fatal(&quot;expected second token&quot;)<br>    }<br>    if budget.TryTake() {<br>        t.Fatal(&quot;expected budget to be exhausted&quot;)<br>    }<br>}</pre><p>The next test checks that concurrent callers cannot consume more tokens than are available.</p><pre>func TestRetryBudgetConcurrent(t *testing.T) {<br>    const (<br>        tokens   = 100<br>        workers  = 1000<br>    )<br><br>   budget := NewRetryBudget(tokens, 1)<br>    for i := 0; i &lt; tokens; i++ {<br>        budget.RecordSuccess()<br>    }<br>    var (<br>        wg       sync.WaitGroup<br>        accepted atomic.Int64<br>    )<br>    wg.Add(workers)<br>    for i := 0; i &lt; workers; i++ {<br>        go func() {<br>            defer wg.Done()<br>            if budget.TryTake() {<br>                accepted.Add(1)<br>            }<br>        }()<br>    }<br>    wg.Wait()<br>    if got := accepted.Load(); got != tokens {<br>        t.Fatalf(<br>            &quot;expected %d successful acquisitions, got %d&quot;,<br>            tokens,<br>            got,<br>        )<br>    }<br>}</pre><p>Run the tests with the race detector:</p><pre>go test -race ./...</pre><p>You should also test the retry executor itself:</p><ul><li>A successful first attempt should not retry.</li><li>A transient failure should retry when budget is available.</li><li>A permanent failure should return immediately.</li><li>An exhausted budget should prevent another attempt.</li><li>Context cancellation should interrupt backoff.</li><li>The maximum attempt count should be respected.</li><li>A successful retry should not replenish the budget.</li><li>A failed initial request should not replenish the budget.</li></ul><p>Finally, test behavior under load. A retry policy that passes unit tests can still create unexpected traffic patterns when thousands of requests fail together.</p><h3>12. Production Checklist</h3><p>Before enabling retries in a production service, verify the following:</p><ul><li>[ ] Maximum attempts include the original request.</li><li>[ ] Retryable errors are explicitly classified.</li><li>[ ] Every request has a deadline or timeout.</li><li>[ ] Backoff is exponential and bounded.</li><li>[ ] Jitter prevents synchronized retries.</li><li>[ ] A shared budget limits aggregate retry traffic.</li><li>[ ] Non-idempotent operations have appropriate protection.</li><li>[ ] Provider-specific rate limits and Retry-After are respected.</li><li>[ ] Retry behavior is observable.</li><li>[ ] Bulkhead permit ownership is clearly defined.</li><li>[ ] Tests cover cancellation, concurrency, and exhaustion.</li><li>[ ] Replica count and aggregate retry capacity are understood.</li></ul><h3>Conclusion</h3><p>Retries are useful, but unlimited retry behavior can turn a temporary dependency failure into a system-wide incident.</p><p>A maximum attempt count protects individual requests. A retry budget protects the service from the combined effect of those requests.</p><p>The key idea is simple: successful traffic can fund a limited amount of additional work, but retries should not be allowed to generate unlimited traffic when the system is already unhealthy.</p><p>In Go, a concurrency-safe token bucket provides a straightforward way to implement this policy. Combined with deadlines, exponential backoff, jitter, bulkheads, and circuit breakers, retry budgets help make failure behavior more predictable.</p><p><strong>Resilience is not about trying again forever. It’s about knowing when another attempt is worth the cost.</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b71fdd606a4e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ACP (Agent Client Protocol): Integrating Coding Agents with the Vercel AI SDK]]></title>
            <link>https://medium.com/@serifcolakel/acp-agent-client-protocol-integrating-coding-agents-with-the-vercel-ai-sdk-65c7761cf67e?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/65c7761cf67e</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[mcp-server]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 21:54:29 GMT</pubDate>
            <atom:updated>2026-10-29T21:54:29.098Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QBj_ue0XZA6trKz1a732Xw.png" /><figcaption>ACP (Agent Client Protocol): Integrating Coding Agents with the Vercel AI SDK</figcaption></figure><p><em>What ACP is, how it relates to MCP, how to integrate it into a TypeScript application, and what it means for teams building agent-powered developer tools.</em></p><h3>1. What Is ACP, and What Problem Does It Solve?</h3><p>When building AI-powered applications, the usual approach is to connect to a model provider, send a prompt, process the response, and optionally handle tool calls.</p><p>Coding agents introduce a different set of requirements.</p><p>Tools such as Claude Code, Gemini CLI, and Codex CLI can work with a codebase, inspect files, execute commands, make changes, and perform multi-step development tasks. Integrating these capabilities into our own applications requires more than simply displaying a model’s text response.</p><p>Without a common protocol, each client may need a custom integration for every agent it wants to support. Agents may also need to implement different interfaces for different clients.</p><p>The <strong>Agent Client Protocol (ACP)</strong> addresses this problem by standardizing communication between coding agents and client applications. It was designed primarily for coding agents and code editors, including IDEs, but its architecture can also support other agent-enabled client interfaces.</p><p>ACP defines how a client and an agent communicate, establish sessions, exchange messages, report progress, and handle interactions such as permission requests.</p><p>The key point is that <strong>ACP is not an LLM or a model provider. It is a communication protocol for integrating agents into applications.</strong></p><p>This distinction matters because a coding agent is more than a text-generation endpoint. It may have its own execution loop, tools, filesystem access, authentication requirements, and session lifecycle.</p><p><strong>Reference:</strong> <a href="https://agentclientprotocol.com/get-started/introduction">Agent Client Protocol — Introduction</a></p><h3>2. ACP vs. MCP: What Is the Difference?</h3><p>ACP and the Model Context Protocol (MCP) are complementary, not competing, technologies. They address different parts of an agent-based architecture.</p><ul><li><strong>ACP (Agent Client Protocol)</strong> standardizes communication between an agent and its client.</li><li><strong>MCP (Model Context Protocol)</strong> standardizes how AI applications and agents interact with tools and external context sources.</li></ul><p>Consider a developer platform that allows an agent to review a GitLab merge request, inspect the related code, make a correction, and run tests.</p><p>The responsibilities could be divided as follows:</p><ol><li>ACP connects the application to the coding agent and carries the interaction.</li><li>MCP makes the required tools available to the agent, such as GitLab operations or access to other services.</li><li>The Vercel AI SDK provides application-level APIs for consuming agent output, including text generation and streaming through a familiar TypeScript interface.</li></ol><p>A simplified architecture looks like this:</p><pre>User<br> |<br> v<br>AI Application<br> |<br> v<br>Vercel AI SDK<br> |<br> v<br>ACP Provider<br> |<br> v<br>ACP-Compatible Agent<br> |<br> +----&gt; Filesystem<br> +----&gt; Terminal / Tests<br> +----&gt; MCP Servers<br>           |<br>           +----&gt; GitLab<br>           +----&gt; Internal APIs<br>           +----&gt; Other Services</pre><p>This diagram illustrates a possible architecture, not a guarantee that every agent supports every capability shown. The available features depend on the agent, its ACP implementation, session configuration, and execution environment.</p><p>One important implementation detail is that the agent can connect to configured MCP servers directly. The application does not necessarily proxy every MCP request through itself.</p><p>This separation helps us design the system around clear responsibilities: ACP handles agent communication, MCP exposes tools, and the application owns the user experience and its policies.</p><p><strong>References:</strong> <a href="https://agentclientprotocol.com/get-started/architecture">ACP Architecture</a> · <a href="https://modelcontextprotocol.io/">MCP Documentation</a></p><h3>3. What Does the ACP Provider in the Vercel AI SDK Offer?</h3><p>The Vercel AI SDK has a community provider that integrates ACP-compatible agents through the LanguageModel interface.</p><p>The package is @mcpc-tech/acp-ai-provider.</p><p>It allows a TypeScript application to use an ACP-compatible agent through familiar AI SDK functions rather than implementing the underlying protocol integration from scratch.</p><p>Its documented capabilities include:</p><ul><li><strong>Agent integration:</strong> Connect to supported agents such as Gemini CLI, Claude Code, and Codex CLI.</li><li><strong>Text generation:</strong> Use generateText to send a task and receive a result.</li><li><strong>Streaming:</strong> Consume output through streamText.</li><li><strong>MCP configuration:</strong> Configure MCP servers for the agent’s session.</li><li><strong>Tool-call reporting:</strong> Expose provider-defined tool-call information through the AI SDK interface.</li><li><strong>Process management:</strong> Start agent processes and manage their lifecycle through the provider.</li></ul><p>The important architectural nuance is that exposing an agent through the LanguageModel interface does not turn that agent into a conventional LLM API.</p><p>The provider offers a common application interface, but the underlying system may still execute multi-step workflows, use external tools, and manage its own state.</p><p>This abstraction is useful, but it does not make all agents behave identically.</p><p><strong>Reference:</strong> <a href="https://ai-sdk.dev/providers/community-providers/acp">AI SDK — ACP Community Provider</a></p><h3>4. How to Integrate ACP</h3><h3>Step 1: Install the dependencies</h3><p>For a Node.js and TypeScript project, install the AI SDK and the ACP provider:</p><pre>pnpm add ai @mcpc-tech/acp-ai-provider</pre><p>Installing the package is only the first step. The selected ACP agent must also be installed, configured, and authenticated according to its own requirements.</p><h3>Step 2: Create an ACP provider</h3><p>The following example uses Gemini CLI with its documented ACP command-line option.</p><pre>import { createACPProvider } from &#39;@mcpc-tech/acp-ai-provider&#39;;<br>import { generateText } from &#39;ai&#39;;<br><br>const provider = createACPProvider({<br>  command: &#39;gemini&#39;,<br>  args: [&#39;--experimental-acp&#39;],<br>  session: {<br>    cwd: process.cwd(),<br>    mcpServers: [],<br>  },<br>});<br><br>const { text } = await generateText({<br>  model: provider.languageModel(),<br>  prompt: &#39;Analyze this codebase and identify its main components.&#39;,<br>});<br><br>console.log(text);</pre><p>Let’s break down the configuration:</p><ul><li>command specifies the executable used to start the agent.</li><li>args passes command-line arguments to that executable.</li><li>cwd defines the working directory for the agent.</li><li>mcpServers configures the MCP servers available to the session.</li><li>provider.languageModel() exposes the configured agent through the AI SDK&#39;s model interface.</li></ul><p>For example, setting cwd to a repository directory allows the agent to work in that context, subject to its capabilities and permissions.</p><p>The command-line arguments are agent-specific. The example assumes a compatible Gemini CLI version is installed and supports the documented ACP mode.</p><h3>Step 3: Stream the response</h3><p>For a long-running coding task, waiting for the entire response before displaying anything can produce a poor user experience.</p><p>The AI SDK provides streamText to consume output incrementally:</p><pre>import { createACPProvider } from &#39;@mcpc-tech/acp-ai-provider&#39;;<br>import { streamText } from &#39;ai&#39;;<br><br>const provider = createACPProvider({<br>  command: &#39;claude-code-acp&#39;,<br>  session: {<br>    cwd: process.cwd(),<br>    mcpServers: [],<br>  },<br>});<br><br>const { textStream } = streamText({<br>  model: provider.languageModel(),<br>  prompt: &#39;Review the error handling in this repository.&#39;,<br>});<br><br>for await (const chunk of textStream) {<br>  process.stdout.write(chunk);<br>}</pre><p>This pattern lets the application consume text as it becomes available.</p><p>However, text streaming and agent execution events are not the same thing. A coding interface may also need to display tool calls, file changes, permission requests, and execution progress.</p><p>Before building that experience, inspect the provider’s supported event mapping and determine which events are exposed through the version you use.</p><h3>Step 4: Provide MCP servers to the agent</h3><p>ACP does not automatically grant an agent access to GitLab, a database, or another internal service. Those capabilities need to be configured and made available to the agent.</p><p>For example, the following configuration provides a filesystem MCP server restricted to /tmp:</p><pre>const provider = createACPProvider({<br>  command: &#39;gemini&#39;,<br>  args: [&#39;--experimental-acp&#39;],<br>  session: {<br>    cwd: process.cwd(),<br>    mcpServers: [<br>      {<br>        type: &#39;stdio&#39;,<br>        name: &#39;filesystem&#39;,<br>        command: &#39;npx&#39;,<br>        args: [<br>          &#39;-y&#39;,<br>          &#39;@modelcontextprotocol/server-filesystem&#39;,<br>          &#39;/tmp&#39;,<br>        ],<br>      },<br>    ],<br>  },<br>});</pre><p>This follows the provider documentation’s MCP configuration pattern. Verify the selected MCP server’s current package, installation instructions, and compatibility before using it.</p><p>A few details are worth keeping in mind:</p><ol><li>The agent must support the MCP configuration and transport being used.</li><li>Filesystem and other tool permissions should be restricted to the minimum required.</li><li>Providing an MCP server does not replace authentication, authorization, or execution isolation.</li></ol><p>There is also a distinction between configuring MCP tools for the agent and exposing tool calls through the AI SDK.</p><p>The provider documentation shows passing provider.tools to AI SDK functions when tool-call information should be available through the SDK interface:</p><pre>const result = await generateText({<br>  model: provider.languageModel(),<br>  prompt: &#39;List the files in the configured workspace.&#39;,<br>  tools: provider.tools,<br>});</pre><p>Here, provider.tools refers to the provider-defined tools. It is not a replacement for configuring the MCP servers that supply capabilities to the agent.</p><p>This distinction becomes important when designing tool-call interfaces, logging agent activity, or building an interactive coding environment.</p><p><strong>Reference:</strong> <a href="https://ai-sdk.dev/providers/community-providers/acp">AI SDK — ACP Provider: Examples and Tool Usage</a></p><h3>5. Advantages of Using ACP</h3><h3>Reuse existing coding agents</h3><p>Building a capable coding agent from scratch involves much more than prompt engineering. It may require repository context management, file operations, command execution, tool orchestration, and recovery from failures.</p><p>Integrating an existing ACP-compatible agent can reduce the amount of infrastructure we need to implement ourselves.</p><p>This is especially useful when we want to experiment with coding workflows without immediately committing to maintaining our own full agent runtime.</p><h3>Reduce client-agent integration overhead</h3><p>ACP introduces a common communication interface for compatible clients and agents.</p><p>Instead of writing a completely different integration for every combination, an application can build against the protocol and add support for compatible agents.</p><p>There will still be agent-specific configuration and behavioral differences, but the common protocol can reduce duplicated integration work.</p><h3>Build our own product experience</h3><p>We do not necessarily have to expose developers to the agent’s original terminal or editor interface.</p><p>We can build a custom interface in a web application, desktop client, or internal developer platform.</p><p>Possible features include:</p><ul><li>Task creation and tracking</li><li>Streaming output and progress indicators</li><li>Repository and session selection</li><li>Permission-request dialogs</li><li>Tool-call visibility</li><li>File-change summaries</li><li>Integration with existing code review workflows</li></ul><p>These are application features built on top of the protocol, not turnkey features supplied by ACP itself.</p><h3>Reuse MCP-based capabilities</h3><p>Organizations that already maintain MCP servers may be able to reuse those integrations with compatible agents.</p><p>For example, an agent could use one set of configured tools to retrieve a merge request, another to read internal documentation, and another to run approved operations.</p><p>The benefit is architectural reuse: tools can be maintained as a separate capability layer rather than being embedded into every agent integration.</p><h3>6. Limitations and Trade-offs</h3><p>ACP simplifies communication, but it does not eliminate the complexity of operating coding agents.</p><h3>The provider is community-maintained</h3><p>The AI SDK ACP integration is a community provider, not a first-party Vercel model provider.</p><p>Being listed in the AI SDK documentation does not mean it has the same ownership, support commitments, or release lifecycle as a first-party package.</p><p>Before adopting it in production, evaluate maintenance activity, release compatibility, issue response, and the cost of maintaining a fallback or internal adapter.</p><h3>Model selection is currently limited</h3><p>The provider documentation states that it exposes a single language model representing the configured agent and does not currently support selecting a specific underlying model through its model interface.</p><p>This matters in environments where model choice is a platform-level concern.</p><p>For example, an organization might require specific models for latency, cost, data-handling, or compliance reasons. If model selection is controlled by the agent’s own configuration, that control may not be available through the ACP provider itself.</p><p>The current limitations should be checked again before adoption because community provider features can change.</p><h3>Process lifecycle and concurrency need attention</h3><p>The documented provider runs in Node.js environments with child-process capabilities. Its documentation also notes that each language model instance spawns a new agent process.</p><p>That creates operational questions:</p><ul><li>When are agent processes started and terminated?</li><li>How are timeouts and cancellations handled?</li><li>What happens if an agent exits unexpectedly?</li><li>Can sessions be resumed, and under what conditions?</li><li>How are concurrent tasks isolated?</li><li>What limits apply to CPU, memory, and execution time?</li></ul><p>Creating a new agent process for every request without a lifecycle strategy can lead to unnecessary startup overhead, resource consumption, and operational instability.</p><p>For a production application, process management should be part of the design rather than an implementation detail left to chance.</p><h3>ACP is not a sandbox</h3><p>An agent may read files, execute commands, and invoke tools. ACP standardizes the communication involved, but the protocol alone does not isolate those operations.</p><p>The effective security boundary depends on the execution environment, the agent, its tool configuration, and the permissions granted to it.</p><p>A production implementation should consider:</p><ul><li>Isolated workspaces or containers</li><li>Least-privilege filesystem and terminal access</li><li>Explicit approval for sensitive operations</li><li>Secure credential handling</li><li>Auditable command and file-change records</li><li>Resource limits and execution timeouts</li><li>Cancellation and recovery behavior</li></ul><p>The client should not assume that every agent implements the same permission model or supports the same capabilities.</p><h3>Compatibility does not mean identical behavior</h3><p>Two ACP-compatible agents can differ in supported features, authentication methods, tools, session handling, and their interpretation of a task.</p><p>ACP reduces communication-level coupling, but it does not guarantee identical results when switching agents.</p><p>Applications should define a common baseline of supported capabilities and make agent-specific differences explicit.</p><p><strong>References:</strong> <a href="https://ai-sdk.dev/providers/community-providers/acp">AI SDK — ACP Provider Limitations</a> · <a href="https://agentclientprotocol.com/protocol/v1/overview">ACP Protocol Overview</a> · <a href="https://agentclientprotocol.com/protocol/v1/security-considerations">ACP Security Considerations</a></p><h3>7. What Can an AI Chapter Lead Contribute?</h3><p>The most valuable outcome is not simply introducing another protocol. It is using the protocol to establish a repeatable approach to agent integration, security, and developer experience across teams.</p><h3>1. Establish a shared agent integration layer</h3><p>Without a common approach, teams may implement their own agent lifecycle management, streaming, permission handling, and error recovery.</p><p>A shared integration layer can provide common abstractions for:</p><ul><li>Agent configuration and selection</li><li>Session creation and cleanup</li><li>Output streaming</li><li>Cancellation and timeouts</li><li>Permission handling</li><li>Error reporting and retries</li><li>Logging and observability</li></ul><p>This allows feature teams to focus on their workflows instead of rebuilding the same integration infrastructure.</p><p>The abstraction should not hide meaningful differences between agents. It should provide a common baseline while allowing explicit capability checks and agent-specific configuration.</p><h3>2. Define when to use ACP, MCP, or a model provider</h3><p>A short architecture guide can help teams decide which technology belongs at which layer.</p><p>RequirementPrimary considerationGenerate text or structured output from a modelAI SDK model providerGive an agent access to internal APIs or toolsMCPConnect a coding agent to a client applicationACPStream task output and supported progress eventsACP integration and AI SDKEnforce permissions and restrict executionApplication policies and runtime isolation</p><p>These are architectural guidelines, not mutually exclusive choices. A single system may use all three.</p><p>The objective is to avoid introducing a protocol simply because it is available. Every component should have a clear responsibility.</p><h3>3. Define a secure execution standard</h3><p>Coding agents introduce security concerns beyond those of a read-only chat interface.</p><p>Before teams use agents to make changes or run commands, establish standards for workspace isolation, tool permissions, credential management, user approvals, and auditability.</p><p>For instance, an agent that reviews a repository may only need read access. An agent that fixes a bug may require write access and permission to run tests. An agent that interacts with a shared environment should have a much stricter policy.</p><p>Permission decisions should be enforced by trusted application and runtime components rather than relying solely on an agent’s instructions.</p><h3>4. Run a measurable pilot</h3><p>A working demo shows that an integration is possible. It does not show that the integration is useful or production-ready.</p><p>A good pilot would be a bounded merge request review and correction workflow:</p><ol><li>The agent inspects a repository and identifies potential issues.</li><li>It explains the relevant files and findings.</li><li>It proposes a change or modifies permitted files.</li><li>It runs the available tests.</li><li>The application presents a summary of the changes and test results.</li><li>A developer reviews the result before it is merged.</li></ol><p>The pilot should be compared with the existing workflow using the same tasks wherever possible.</p><p>Useful metrics include task completion rate, end-to-end duration, process startup overhead, tool-call failures, resource consumption, human intervention, and the number of changes accepted after review.</p><p>Security checks and regression tests should be treated as acceptance criteria, not optional improvements.</p><h3>5. Create a reference implementation and share learnings</h3><p>Instead of asking each team to interpret protocol documentation independently, provide a small reference application.</p><p>It should demonstrate provider configuration, MCP integration, streaming, process lifecycle management, permission handling, and failure scenarios.</p><p>Teams can then reuse the implementation and contribute improvements.</p><p>This approach makes the learning transferable: the organization develops an integration standard and a practical understanding of where the technology helps, where it introduces complexity, and which limitations still need work.</p><h3>8. Questions to Answer Before Production Adoption</h3><p>Before choosing ACP for a production workflow, I would evaluate the following:</p><ul><li>Does our selected agent implement ACP, and which version and capabilities does it support?</li><li>Does the provider expose the events and controls required by our application?</li><li>Where does the agent process run, and what resources can it access?</li><li>How are credentials, permissions, and approval decisions managed?</li><li>How are sessions, cancellation, timeouts, and process termination handled?</li><li>How are concurrent users and tasks isolated?</li><li>How will we measure reliability, cost, performance, and developer productivity?</li><li>What is our plan if the community provider becomes incompatible with a new AI SDK release?</li></ul><p>The key is to separate protocol compatibility from operational readiness.</p><p>A compatible agent may be easy to connect but still require substantial engineering around security, process management, observability, and user experience.</p><h3>Conclusion</h3><p>ACP is worth evaluating when we want to integrate coding agents into our own applications without building every agent-specific communication layer from scratch.</p><p>The Vercel AI SDK’s community provider makes this approach accessible to TypeScript applications by exposing ACP-compatible agents through familiar APIs.</p><p>But ACP should not be treated as a universal replacement for model APIs or as an alternative to MCP. Its value is more specific: it standardizes communication between an agent and the client that uses it, while MCP can provide the tools an agent needs.</p><p>For an AI Chapter Lead, the opportunity is to turn that capability into a reusable engineering practice: define clear architectural boundaries, standardize secure integration patterns, run controlled pilots, and evaluate outcomes using measurable criteria.</p><p><strong>My recommended starting point:</strong> choose one bounded coding workflow, build a small ACP-based reference implementation, and compare it with the existing approach. Make the production decision based on compatibility, operational effort, security, and measured results — not simply on whether the demo works.</p><h3>References and Further Reading</h3><ol><li><a href="https://ai-sdk.dev/providers/community-providers/acp">AI SDK — ACP Community Provider</a> — Installation, configuration, examples, tool usage, and documented limitations.</li><li><a href="https://agentclientprotocol.com/get-started/introduction">Agent Client Protocol — Introduction</a> — ACP’s purpose and the interoperability problem it addresses.</li><li><a href="https://agentclientprotocol.com/get-started/architecture">Agent Client Protocol — Architecture</a> — Client-agent communication and MCP integration.</li><li><a href="https://agentclientprotocol.com/protocol/v1/overview">Agent Client Protocol — Protocol Overview</a> — Sessions, messages, progress updates, and permission requests.</li><li><a href="https://agentclientprotocol.com/protocol/v1/security-considerations">Agent Client Protocol — Security Considerations</a> — Security boundaries and integration considerations.</li><li><a href="https://github.com/agentclientprotocol/typescript-sdk">Agent Client Protocol — TypeScript SDK</a> — The TypeScript SDK for building ACP clients and agents.</li><li><a href="https://modelcontextprotocol.io/">Model Context Protocol Documentation</a> — MCP concepts, tools, and integration patterns.</li><li><a href="https://github.com/mcpc-tech/mcpc">MCPC — GitHub Repository</a> — The project associated with the ACP AI SDK provider.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=65c7761cf67e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bulkheads in Go: Isolating Failures Across Dependencies]]></title>
            <link>https://medium.com/@serifcolakel/bulkheads-in-go-isolating-failures-across-dependencies-6592287ca492?source=rss-d6c4531665d------2</link>
            <guid isPermaLink="false">https://medium.com/p/6592287ca492</guid>
            <category><![CDATA[productivity]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Serif Colakel]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 21:46:48 GMT</pubDate>
            <atom:updated>2026-10-29T21:46:48.187Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OQ_FYdkMfonxJ7RLOP5nug.png" /><figcaption>Bulkheads in Go: Isolating Failures Across Dependencies Image</figcaption></figure><p>A service can have healthy code, reasonable timeouts, and a circuit breaker — and still suffer a cascading failure.</p><p>Imagine a Go API that serves portfolio data, market prices, and investment reports. It calls three external dependencies:</p><ul><li>A market data provider</li><li>A portfolio database</li><li>A reporting service</li></ul><p>The reporting service becomes slow. Requests begin waiting for it, goroutines remain occupied, and incoming traffic continues to grow. Eventually, requests that do not even need reports start failing because they compete for the same resources.</p><p>The original failure was isolated to one dependency. The application’s resource usage was not.</p><p>This is the problem the <strong>bulkhead pattern</strong> is designed to address.</p><p>In this article, we’ll build dependency-specific bulkheads in Go, explore admission and waiting policies, examine common implementation mistakes, and discuss how to verify that isolation actually works under load.</p><h3>1. What is the bulkhead pattern?</h3><p>The name comes from the watertight compartments inside a ship. If one compartment floods, the others should remain intact.</p><p>In software, a bulkhead creates a resource boundary around a particular dependency, workload, or tenant. When one boundary reaches capacity, work inside that boundary is limited without automatically consuming capacity reserved for others.</p><p>Consider a service with three downstream dependencies.</p><p>Without isolation:</p><pre>┌────────────────────┐<br>Incoming ───────►│   Shared Capacity  │<br>                 └─────────┬──────────┘<br>                           │<br>                ┌──────────┼──────────┐<br>                ▼          ▼          ▼<br>             Market     Portfolio   Reporting<br>             Data       Database    Service</pre><p>If reporting requests become slow, they can consume the shared capacity needed by market data and portfolio requests.</p><p>With dependency-specific bulkheads:</p><pre>┌────────────────────┐<br>Incoming ───────►│    Go API          │<br>                 └─────────┬──────────┘<br>                           │<br>              ┌────────────┼────────────┐<br>              ▼            ▼            ▼<br>          ┌────────┐   ┌────────┐   ┌────────┐<br>          │ Market │   │Portfolio│  │Report  │<br>          │ Limit  │   │ Limit   │  │ Limit  │<br>          └───┬────┘   └───┬────┘   └───┬────┘<br>              ▼            ▼            ▼<br>          Market API    Database     Report API</pre><p>Each dependency receives its own concurrency budget.</p><p>If the reporting bulkhead is full, new reporting work can be rejected or briefly delayed according to policy. Market data and portfolio operations retain their own permits.</p><p>The important property is not that failures disappear. It is that <strong>one dependency cannot consume all the capacity reserved for unrelated work</strong>.</p><h3>2. Bulkheads are not circuit breakers</h3><p>Both patterns help prevent cascading failures, but they protect against different problems.</p><p>PatternWhat it protects againstTimeoutWaiting too long for an operationRate limiterExcessive request frequencyLoad sheddingAccepting more work than the service can safely handleCircuit breakerRepeated calls to a dependency that is failingBulkheadOne workload consuming resources needed by another</p><p>Suppose a reporting API has a 10-second response time.</p><p>A timeout limits how long a caller waits. A circuit breaker may stop future calls after repeated failures. Neither automatically prevents 100 concurrent reporting calls from consuming every available application slot.</p><p>A bulkhead limits that concurrency independently.</p><p>These patterns complement one another. A typical dependency call may use a bulkhead, a deadline, and a circuit breaker, with a carefully bounded retry policy where retries are safe.</p><h3>3. Start with a bounded semaphore</h3><p>Go’s channel primitives provide a straightforward way to limit concurrent operations.</p><p>A buffered channel can represent available permits. Each operation acquires a permit before executing and releases it when finished.</p><p>For a dependency that should allow at most 10 concurrent calls:</p><pre>var reportingSlots = make(chan struct{}, 10)</pre><p>However, the acquisition policy matters.</p><p>If all permits are occupied, should a new call wait for capacity or fail immediately?</p><p>For an already-slow dependency, waiting indefinitely can simply move the queue into application memory. In many latency-sensitive paths, a non-blocking bulkhead is preferable.</p><p>Let’s implement a reusable version.</p><h3>4. Implementing a fail-fast bulkhead</h3><p>The following implementation limits active operations and returns a recognizable error when capacity is exhausted.</p><pre>package bulkhead<br><br>import (<br>    &quot;context&quot;<br>    &quot;errors&quot;<br>)<br><br>var ErrFull = errors.New(&quot;bulkhead capacity exhausted&quot;)<br><br>type Bulkhead struct {<br>    slots chan struct{}<br>}<br><br>func New(maxConcurrent int) *Bulkhead {<br>    if maxConcurrent &lt;= 0 {<br>        panic(&quot;maxConcurrent must be greater than zero&quot;)<br>    }<br>    return &amp;Bulkhead{<br>        slots: make(chan struct{}, maxConcurrent),<br>    }<br>}<br><br>func (b *Bulkhead) Do(<br>    ctx context.Context,<br>    fn func(context.Context) error,<br>) error {<br>    if err := ctx.Err(); err != nil {<br>        return err<br>    }<br>    select {<br>    case b.slots &lt;- struct{}{}:<br>        // A permit has been acquired.<br>    case &lt;-ctx.Done():<br>        return ctx.Err()<br>    default:<br>        return ErrFull<br>    }<br>    defer func() {<br>        &lt;-b.slots<br>    }()<br>    // Cancellation may race with permit acquisition.<br>    if err := ctx.Err(); err != nil {<br>        return err<br>    }<br>    return fn(ctx)<br>}</pre><p>This implementation deliberately does not queue callers waiting for a permit.</p><p>If capacity is available, the operation proceeds. If every slot is occupied, the caller receives ErrFull immediately.</p><p>The second context check handles the case where cancellation races with permit acquisition. If the context is canceled by that point, the deferred release returns the permit.</p><p>The function passed to Do should honor its context and should not start detached work that outlives the operation without an independent lifecycle policy.</p><h3>Using the bulkhead</h3><pre>var reportBulkhead = bulkhead.New(10)<br><br>func GenerateReport(<br>    ctx context.Context,<br>    reporter Reporter,<br>) error {<br>    return reportBulkhead.Do(ctx, func(<br>        ctx context.Context,<br>    ) error {<br>        return reporter.Generate(ctx)<br>    })<br>}</pre><p>Reporter is an application-defined interface in this example.</p><p>The reporting dependency now has an independent concurrency limit. If it becomes slow and all 10 permits are occupied, additional reporting calls fail fast rather than accumulating behind the dependency.</p><p>The limit is a policy choice, not a universal recommendation. Choose it based on load testing, downstream capacity, latency objectives, and the number of application instances.</p><h3>5. Choose the right rejection behavior</h3><p>A full bulkhead does not always need to produce an HTTP error directly. The appropriate response depends on the caller and the operation.</p><p>For example:</p><pre>func handleReport(<br>    w http.ResponseWriter,<br>    r *http.Request,<br>) {<br>    err := GenerateReport(r.Context(), reporter)<br><br>    switch {<br>    case errors.Is(err, bulkhead.ErrFull):<br>        http.Error(<br>            w,<br>            &quot;reporting capacity temporarily exhausted&quot;,<br>            http.StatusServiceUnavailable,<br>        )<br>        return<br>    case errors.Is(err, context.Canceled):<br>        // The client or upstream caller canceled the request.<br>        return<br>    case errors.Is(err, context.DeadlineExceeded):<br>        http.Error(<br>            w,<br>            &quot;report generation timed out&quot;,<br>            http.StatusGatewayTimeout,<br>        )<br>        return<br>    case err != nil:<br>        http.Error(<br>            w,<br>            &quot;report generation failed&quot;,<br>            http.StatusBadGateway,<br>        )<br>        return<br>    }<br>    w.WriteHeader(http.StatusNoContent)<br>}</pre><p>This is a simplified handler; the application would need to define reporter, imports, and its actual response contract.</p><p>The important distinction is between <strong>admission rejection</strong>, <strong>caller cancellation</strong>, <strong>deadline expiration</strong>, and <strong>dependency failure</strong>. They are different events and should not be collapsed into one generic error.</p><p>For public APIs, document the overload response and decide whether a 503 or another status is appropriate for the contract. Avoid encouraging immediate retries when the dependency is already saturated.</p><h3>6. A bulkhead must protect the actual bottleneck</h3><p>A semaphore limits how many operations enter a code path. It does not automatically isolate every resource used by that path.</p><p>Consider a Go service that calls three downstream APIs through a single http.Client.</p><p>The client uses a shared http.Transport, which manages connection pooling and other transport resources. Even if the application has separate concurrency semaphores, shared transport configuration or other shared resources may still create contention.</p><p>A useful design starts by identifying what is being protected:</p><ul><li>Concurrent requests to a dependency</li><li>Connections to a database</li><li>Worker capacity for background jobs</li><li>CPU-intensive computation</li><li>Memory-intensive report generation</li><li>Per-tenant capacity</li><li>External provider quotas</li></ul><p>Then enforce limits at the relevant resource boundary.</p><p>For outbound HTTP calls, use dedicated transports when separate connection-pool configuration or transport-level isolation is useful:</p><pre>func newHTTPClient(maxConns int) *http.Client {<br>    transport := &amp;http.Transport{<br>        MaxConnsPerHost:     maxConns,<br>        MaxIdleConns:        maxConns,<br>        MaxIdleConnsPerHost: maxConns,<br>    }<br><br>    return &amp;http.Client{<br>        Transport: transport,<br>        Timeout:   2 * time.Second,<br>    }<br>}<br>var (<br>    marketClient = newHTTPClient(20)<br>    reportClient = newHTTPClient(5)<br>)</pre><p>This snippet requires the net/http and time imports.</p><p>The clients have separate transports and different connection limits. The timeout is an example; production systems should choose deadlines based on their latency budgets and may prefer request contexts plus explicit transport settings.</p><p>Connection limits are not a replacement for application-level concurrency limits. A connection can be reused, and one operation can involve more than one resource. Use both controls when they protect distinct bottlenecks.</p><p>Also remember that a custom transport changes the default transport behavior. Configure TLS, proxy handling, connection lifetimes, idle timeouts, and other relevant settings according to your environment.</p><h3>7. Waiting queues: useful, but not free</h3><p>Fail-fast admission is not always the best policy.</p><p>Some operations can tolerate a short wait for capacity. In that case, a bounded waiting policy may improve utilization without allowing unbounded accumulation.</p><p>There are three common approaches:</p><ol><li><strong>Fail fast:</strong> Reject immediately when no permit is available.</li><li><strong>Wait with a deadline:</strong> Wait for a permit, but only until the caller’s context expires.</li><li><strong>Bound active work and queued work separately:</strong> Allow a defined number of operations to run and a small number to wait.</li></ol><p>The third approach requires particular care. A semaphore that limits active calls does not limit the number of goroutines waiting to acquire it.</p><p>A context-aware waiting version can be implemented like this:</p><pre>func (b *Bulkhead) DoWait(<br>    ctx context.Context,<br>    fn func(context.Context) error,<br>) error {<br>    if err := ctx.Err(); err != nil {<br>        return err<br>    }<br><br>    select {<br>    case b.slots &lt;- struct{}{}:<br>    case &lt;-ctx.Done():<br>        return ctx.Err()<br>    }<br>    defer func() {<br>        &lt;-b.slots<br>    }()<br>    if err := ctx.Err(); err != nil {<br>        return err<br>    }<br>    return fn(ctx)<br>}</pre><p>This method waits for a permit or cancellation. It does <strong>not</strong> bound the number of callers waiting in goroutines.</p><p>If the service can receive an unbounded number of waiting callers, use an explicit bounded admission queue, reject before creating excess work, or use a bounded worker pool.</p><p>The distinction is important: a concurrency limit without a queue bound can still leave the system with too many waiting goroutines.</p><h3>8. Isolate workloads, not just services</h3><p>Dependency-level bulkheads are useful, but some applications need additional boundaries.</p><p>Consider a service with the following operations:</p><ul><li>Interactive portfolio queries</li><li>Historical analytics</li><li>Exporting large reports</li><li>Background notifications</li></ul><p>These workloads may share dependencies while having very different resource profiles.</p><p>A report export can occupy database connections for much longer than a small portfolio query. If both share the same database pool and the same admission policy, the reporting workload may starve interactive traffic.</p><p>Possible isolation strategies include:</p><ul><li>Separate concurrency limits by workload.</li><li>Dedicated worker pools for long-running jobs.</li><li>Bounded queues for background processing.</li><li>Database connection budgets for distinct workloads, where operationally feasible.</li><li>Reserved capacity for latency-sensitive operations.</li><li>Per-tenant limits to prevent one customer from dominating capacity.</li></ul><p>Do not create a separate pool for every function by default. Each additional boundary introduces configuration, monitoring, and capacity-planning costs.</p><p>The goal is meaningful isolation around resources that have different failure modes or service-level requirements.</p><h3>9. Beware of multiplicative concurrency</h3><p>A local bulkhead limit is not necessarily a global limit.</p><p>Suppose a service runs 8 replicas, and each replica allows 20 concurrent calls to a reporting provider.</p><p>The potential aggregate concurrency is:</p><p>[ 8 \times 20 = 160 ]</p><p>If the provider can safely handle only 50 concurrent calls, the local configuration does not protect the global dependency.</p><p>This is one of the most common capacity-planning mistakes in distributed systems.</p><p>When choosing limits, account for:</p><ul><li>The maximum number of replicas.</li><li>Autoscaling behavior.</li><li>Other applications calling the same dependency.</li><li>Provider-wide quotas.</li><li>Deployment overlap during rolling updates.</li><li>Traffic distribution and uneven load.</li></ul><p>A local bulkhead is excellent for protecting each process. A global quota may require a shared admission mechanism, provider-side limits, a centralized dispatcher, or a different partitioning strategy.</p><p>Avoid introducing a distributed semaphore unless global coordination is genuinely needed. The coordination service can become another dependency whose failure must be handled.</p><h3>10. Combining bulkheads with circuit breakers and retries</h3><p>These patterns solve different parts of the same failure scenario.</p><p>Imagine a reporting provider starts timing out.</p><ol><li>The bulkhead limits how many reporting calls can be active.</li><li>The deadline prevents each call from waiting indefinitely.</li><li>The circuit breaker can stop repeated calls after a failure threshold is reached.</li><li>A retry policy may retry selected transient failures, within a strict budget.</li><li>Load shedding rejects additional work when admission capacity is exhausted.</li></ol><p>Order and interaction matter.</p><p>If retries happen inside a bulkhead permit, the permit remains occupied across attempts and backoff periods. This can reduce pressure on the downstream service, but it also ties up local capacity while no useful work is being performed.</p><p>If retries happen outside the bulkhead, every attempt must reacquire capacity, which may make admission more responsive but can complicate retry scheduling.</p><p>Neither choice is universally correct. Define whether the permit represents a whole logical operation or one outbound attempt, then test the behavior under slow failures.</p><p>Never retry a bulkhead rejection immediately by default. The rejection indicates that local capacity is exhausted; an immediate retry simply competes for the same capacity again.</p><p>For operations with side effects, retries also require idempotency or another mechanism that makes repeated attempts safe.</p><h3>11. Observability: prove that isolation works</h3><p>A bulkhead without metrics is difficult to tune and nearly impossible to validate during an incident.</p><p>For each bulkhead, consider recording:</p><ul><li>Active operations</li><li>Configured concurrency capacity</li><li>Rejected acquisitions</li><li>Wait duration, if waiting is allowed</li><li>Operation duration</li><li>Timeouts and cancellations</li><li>Downstream error rates</li><li>Circuit-breaker state, where applicable</li></ul><p>Useful metric names might include:</p><pre>dependency_bulkhead_active<br>dependency_bulkhead_capacity<br>dependency_bulkhead_rejected_total<br>dependency_request_duration_seconds<br>dependency_request_errors_total</pre><p>Use bounded labels such as a dependency name or workload class. Avoid raw user IDs, request IDs, and other high-cardinality values.</p><p>Monitor the bulkheads independently. A global saturation metric can hide the fact that reporting is exhausted while market data remains healthy.</p><p>A useful operational question is not merely, “Is the service saturated?”</p><p>It is: <strong>Which resource boundary is saturated, how long has it been saturated, and are unrelated workloads still meeting their latency objectives?</strong></p><h3>12. Test the failure boundary</h3><p>A bulkhead should be tested with a deliberately slow dependency.</p><p>For example, configure a dependency bulkhead with a capacity of two, start two operations that block on a test-controlled channel, and then submit a third operation.</p><p>The third operation should receive ErrFull without invoking its callback.</p><p>A simplified test:</p><pre>func TestBulkheadRejectsWhenFull(t *testing.T) {<br>    b := bulkhead.New(2)<br><br>    release := make(chan struct{})<br>    started := make(chan struct{}, 2)<br>    operation := func(context.Context) error {<br>        started &lt;- struct{}{}<br>        &lt;-release<br>        return nil<br>    }<br>    var wg sync.WaitGroup<br>    for range 2 {<br>        wg.Add(1)<br>        go func() {<br>            defer wg.Done()<br>            _ = b.Do(context.Background(), operation)<br>        }()<br>    }<br>    // Wait until both permits are occupied.<br>    &lt;-started<br>    &lt;-started<br>    err := b.Do(<br>        context.Background(),<br>        func(context.Context) error {<br>            t.Fatal(&quot;operation should not have been admitted&quot;)<br>            return nil<br>        },<br>    )<br>    if !errors.Is(err, bulkhead.ErrFull) {<br>        t.Fatalf(&quot;got %v, want %v&quot;, err, bulkhead.ErrFull)<br>    }<br>    close(release)<br>    wg.Wait()<br>}</pre><p>This test requires the usual testing, context, sync, and errors imports, and the bulkhead package import. It assumes the test is in a separate package where the bulkhead package can be referenced.</p><p>The test uses synchronization channels rather than arbitrary sleeps to establish that both permits are occupied.</p><p>Additional tests should cover:</p><ul><li>Cancellation before acquisition.</li><li>Cancellation racing with acquisition.</li><li>Permit release after errors and panics.</li><li>Recovery after a dependency becomes healthy.</li><li>Waiting callers and bounded queues, if supported.</li><li>Isolation between two independent bulkheads.</li><li>Maximum aggregate concurrency across replicas.</li><li>Saturation of one dependency while unrelated operations remain responsive.</li></ul><p>For a full integration test, make one fake dependency slow or unresponsive and verify that requests to other dependencies still meet their expected latency and success-rate objectives.</p><h3>13. Common mistakes</h3><p><strong>Using one shared limiter for every dependency.</strong> This can recreate the very contention the bulkhead was intended to prevent.</p><p><strong>Bounding concurrency but not waiting callers.</strong> A semaphore can limit active operations while allowing many goroutines to accumulate while waiting.</p><p><strong>Assuming a local limit is a global quota.</strong> Per-replica capacity multiplies across replicas and deployments.</p><p><strong>Holding permits during unnecessary work.</strong> Long retry backoffs, unrelated processing, or detached tasks can occupy capacity longer than intended.</p><p><strong>Creating too many isolated pools.</strong> Excessive fragmentation can leave some pools idle while others reject work. Isolation should follow real workload and resource boundaries.</p><p><strong>Ignoring shared infrastructure.</strong> Separate application semaphores do not automatically isolate database pools, HTTP transports, CPU, memory, or downstream quotas.</p><p><strong>Measuring only errors.</strong> A bulkhead may be working correctly by rejecting excess work. You need rejection, saturation, latency, and dependency health metrics to interpret the behavior.</p><h3>14. Production checklist</h3><p>Before deploying a bulkhead, verify that:</p><ul><li>The protected resource and isolation boundary are explicit.</li><li>Each materially different dependency or workload has an appropriate capacity budget.</li><li>Both active concurrency and queued work are bounded where necessary.</li><li>Context deadlines and cancellation are propagated.</li><li>Rejections are distinguishable from timeouts and dependency failures.</li><li>Permits are released on every exit path.</li><li>Retry policies do not amplify saturation.</li><li>Per-instance limits are compatible with global dependency quotas.</li><li>Metrics expose saturation and rejection per bulkhead.</li><li>Tests demonstrate that one slow dependency cannot starve unrelated workloads.</li></ul><h3>Final thoughts</h3><p>Bulkheads do not make dependencies reliable. They make dependency failures more contained.</p><p>In Go, channels, semaphores, dedicated clients, bounded worker pools, and resource-specific limits can establish useful isolation boundaries. The hard part is not writing a semaphore. It is deciding what must be isolated, how much capacity each workload deserves, and what should happen when that capacity is exhausted.</p><p>A reliable system should not allow one slow dependency to determine the fate of every other operation.</p><p><strong>The goal is not to eliminate every failure. It is to keep one failure from becoming everyone else’s failure.</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6592287ca492" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>