<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ivan Jurina</title>
    <description>The latest articles on DEV Community by Ivan Jurina (@ivan_jurina_708793b01312e).</description>
    <link>https://dev.to/ivan_jurina_708793b01312e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4040212%2F6728e97f-0863-4bde-8ca9-8088dd9c5183.jpeg</url>
      <title>DEV Community: Ivan Jurina</title>
      <link>https://dev.to/ivan_jurina_708793b01312e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ivan_jurina_708793b01312e"/>
    <language>en</language>
    <item>
      <title>building enterprise multi-agent workflows in .net with mistral</title>
      <dc:creator>Ivan Jurina</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:54:01 +0000</pubDate>
      <link>https://dev.to/ivan_jurina_708793b01312e/building-enterprise-multi-agent-workflows-in-net-with-mistral-1ik</link>
      <guid>https://dev.to/ivan_jurina_708793b01312e/building-enterprise-multi-agent-workflows-in-net-with-mistral-1ik</guid>
      <description>&lt;p&gt;most people know &lt;a href="https://mistral.ai" rel="noopener noreferrer"&gt;Mistral&lt;/a&gt; for its chat models. the part i find more interesting for enterprise work is the &lt;a href="https://docs.mistral.ai/studio-api/agents/introduction" rel="noopener noreferrer"&gt;Agents API&lt;/a&gt;: persistent agents with instructions and tools, stateful conversations you can resume, built-in connectors (web search, code interpreter, document library), and handoffs so one agent can delegate to another.&lt;/p&gt;

&lt;p&gt;the .net story stops short of this. the community sdks (tghamm's is genuinely good) cover chat completions, embeddings and function calling. they don't cover the agentic layer. so if you're a .net shop that wants to build a multi-agent workflow on mistral, you're writing raw http. i didn't want to, so i built &lt;a href="https://github.com/ivanjurina/mistral-agents-dotnet" rel="noopener noreferrer"&gt;Mistral.Agents.Net&lt;/a&gt;. here's the design and the one wire-format detail that cost me a debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  agents, not just completions
&lt;/h2&gt;

&lt;p&gt;a chat completion is stateless: you send messages, you get a reply, you manage all the history yourself. an agent is a stored object with instructions and tools, and a conversation is a stateful thread you can continue by id. that difference matters for enterprise workflows, where a "session" spans many turns and you want the platform to hold the state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAgentAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;CreateAgentRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mistral-medium-latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Financial Analyst"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Instructions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Use the code interpreter for math and web search for current facts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AgentTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CodeInterpreter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;AgentTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WebSearch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartConversationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;StartConversationRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AgentId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Inputs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"what was 15% of last quarter's revenue if it was 12.4M?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the response isn't a single message. it's a list of outputs: tool executions, message chunks, function calls, handoffs. the library gives you &lt;code&gt;OutputText&lt;/code&gt; for the common case and &lt;code&gt;Outputs&lt;/code&gt; for the raw stream, plus a &lt;code&gt;Root&lt;/code&gt; JsonElement escape hatch for anything the typed model doesn't cover yet. same philosophy i used for the serpapi and elevenlabs clients: typed where it helps, raw where you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  handoffs: one agent delegating to another
&lt;/h2&gt;

&lt;p&gt;the enterprise-interesting feature is handoffs. you give an agent the ids of other agents it may delegate to, and the platform routes work between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAgentAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;CreateAgentRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mistral-medium-latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Research Agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AgentTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WebSearch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;analyst&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAgentAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;CreateAgentRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mistral-medium-latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Financial Analyst"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Handoffs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// delegate research&lt;/span&gt;
    &lt;span class="n"&gt;Tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AgentTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CodeInterpreter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ask the analyst a question that needs current market data and it hands off to the researcher, which uses web search, and the answer comes back through the analyst. you orchestrate multiple specialized agents without writing the routing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  your own code as a tool
&lt;/h2&gt;

&lt;p&gt;built-in connectors are great, but enterprise value is in your private data. a function tool exposes your c# to the agent: it decides when to call, you run it, you return the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentTool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FunctionDefinition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"get_internal_metric"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Returns a private company metric."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"""{"&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="s"&gt;":{"&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="s"&gt;":{"&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="s"&gt;":"&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="s"&gt;"}}}"""&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then, when the agent calls it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FunctionCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ParseArguments&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;LookUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FunctionName&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RootElement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SubmitToolResultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConversationId&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCallId&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;i tested this end to end: asked "what is 15% of our q3 revenue?", watched the agent call &lt;code&gt;get_internal_metric&lt;/code&gt;, feed the private number back, and compute 15% with the code interpreter. the whole loop, from .net.&lt;/p&gt;

&lt;h2&gt;
  
  
  the detail the docs don't tell you
&lt;/h2&gt;

&lt;p&gt;here's the debugging session. the docs show function-call arguments as a json object. the live api returns them as a json &lt;strong&gt;string&lt;/strong&gt; — a stringified object you have to parse. my first live run threw &lt;code&gt;element has type 'String'&lt;/code&gt; the instant the agent called a function, because i was calling &lt;code&gt;GetProperty&lt;/code&gt; on what i thought was an object.&lt;/p&gt;

&lt;p&gt;this is a common llm-api quirk (openai does the same), but it's exactly the kind of thing you only learn by running against the real service, not by reading the reference. so the library handles it for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;JsonDocument&lt;/span&gt; &lt;span class="nf"&gt;ParseArguments&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValueKind&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;JsonValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"{}"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;()!),&lt;/span&gt;
        &lt;span class="n"&gt;JsonValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;JsonValueKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Array&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRawText&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JsonDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ParseArguments()&lt;/code&gt; normalizes both forms, so your code never sees the difference. every wrinkle like this that the library absorbs is a wrinkle your users don't hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's real
&lt;/h2&gt;

&lt;p&gt;agents, conversations, connectors, handoffs and function tools are all live-verified against the real api — the revenue demo above actually runs. the library is net8.0, zero dependencies, async-first with cancellation, typed requests with a raw escape hatch, and an offline test suite that replays captured api frames (including the string-arguments case) so ci needs no key or credits.&lt;/p&gt;

&lt;p&gt;it's on nuget as &lt;code&gt;Mistral.Agents.Net&lt;/code&gt; and the source is on my github. .net is a large enterprise audience for agentic ai and right now it has no official path to mistral's agents platform. if you're at mistral and reading this: happy to help close that gap properly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;originally published at &lt;a href="https://www.ivanjurina.com/index.php/2026/07/22/building-enterprise-multi-agent-workflows-in-net-with-mistral/" rel="noopener noreferrer"&gt;ivanjurina.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>api</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>giving a .net app a voice: building on the elevenlabs agents platform</title>
      <dc:creator>Ivan Jurina</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:56:22 +0000</pubDate>
      <link>https://dev.to/ivan_jurina_708793b01312e/giving-a-net-app-a-voice-building-on-the-elevenlabs-agents-platform-5hcl</link>
      <guid>https://dev.to/ivan_jurina_708793b01312e/giving-a-net-app-a-voice-building-on-the-elevenlabs-agents-platform-5hcl</guid>
      <description>&lt;p&gt;&lt;a href="https://elevenlabs.io" rel="noopener noreferrer"&gt;ElevenLabs&lt;/a&gt; is best known for text to speech, but the thing i find most interesting is &lt;a href="https://elevenlabs.io/docs/eleven-agents/overview" rel="noopener noreferrer"&gt;ElevenAgents&lt;/a&gt;: you configure an agent with a prompt, a voice and some tools, and it handles the whole voice loop. speech to text, the llm, turn-taking, interruptions, text to speech. you just open a websocket and talk.&lt;/p&gt;

&lt;p&gt;they ship sdks for python, typescript, kotlin and swift. nothing for .net. so if you want to build a voice agent from c#, you're hand-rolling the websocket protocol. i didn't want to do that every time, so i built &lt;a href="https://github.com/ivanjurina/elevenagents-dotnet" rel="noopener noreferrer"&gt;ElevenAgents.Net&lt;/a&gt;. here's what the protocol actually looks like and the two design decisions that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  the protocol is a typed event stream
&lt;/h2&gt;

&lt;p&gt;after the handshake, the agent sends a &lt;code&gt;conversation_initiation_metadata&lt;/code&gt; event with a conversation id and the negotiated audio formats. then it's a stream of json events, each with a &lt;code&gt;type&lt;/code&gt;: &lt;code&gt;user_transcript&lt;/code&gt;, &lt;code&gt;agent_response&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;, &lt;code&gt;interruption&lt;/code&gt;, &lt;code&gt;vad_score&lt;/code&gt;, &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;client_tool_call&lt;/code&gt;. you send events back: &lt;code&gt;user_message&lt;/code&gt;, &lt;code&gt;user_audio_chunk&lt;/code&gt;, &lt;code&gt;client_tool_result&lt;/code&gt;, &lt;code&gt;pong&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;the naive way to model this is a big enum and a switch. the problem is the event list is long and still growing, and you don't want your library to break the day elevenlabs adds an event type. so: typed classes for the events people actually handle, and a raw &lt;code&gt;JsonElement&lt;/code&gt; on the base class for everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReceiveEventsAsync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;AgentResponseEvent&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"agent: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;UserTranscriptEvent&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"you: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;UnknownEvent&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// future event types still usable&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UnknownEvent.Raw&lt;/code&gt; means a new server event is never a breaking change. you can read it today and i can add a typed wrapper later without anyone's code changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  two protocol chores the library should just do
&lt;/h2&gt;

&lt;p&gt;two things in the protocol are pure mechanics that no caller should have to think about.&lt;/p&gt;

&lt;p&gt;first, ping/pong. the server sends &lt;code&gt;ping&lt;/code&gt; events with an id and expects a matching &lt;code&gt;pong&lt;/code&gt; for latency measurement. forget it and the connection looks dead. so the library answers pings itself, before the event is even handed to you.&lt;/p&gt;

&lt;p&gt;second, client tools. this is the good part. an agent can be configured with "client tools", and mid-conversation it emits a &lt;code&gt;client_tool_call&lt;/code&gt; event: run this function with these parameters and give me the result. that's how a voice agent does something real instead of just talking. the library lets you register a handler and wires up the response frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_order_status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orderId"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;$"Order &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;when the agent calls the tool, your code runs and the result is spoken back. if your handler throws, the library reports it as a tool error instead of dropping the turn. this is stock async-with-cancellation c#, which is the whole point: it should feel like the rest of your codebase, not like a protocol you're fighting.&lt;/p&gt;

&lt;h2&gt;
  
  
  the payoff: your existing .net code, now with a voice
&lt;/h2&gt;

&lt;p&gt;here's the part i actually built this for. if you already use &lt;a href="https://github.com/microsoft/semantic-kernel" rel="noopener noreferrer"&gt;Semantic Kernel&lt;/a&gt;, you have plugins: c# methods decorated as kernel functions. those are exactly what a voice agent's tools want to be. so there's a companion package that maps every kernel function to an elevenlabs client tool in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Kernel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddFromType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrdersPlugin&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AgentConversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConnectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ConversationOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AgentId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agentId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;KernelToolBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// every function is now callable by voice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the agent handles speech, the model and the voice. your business logic runs when the model decides it needs it. the same &lt;code&gt;OrdersPlugin&lt;/code&gt; you'd expose to a text chat agent now works over a phone call, and you wrote it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's real and what's next
&lt;/h2&gt;

&lt;p&gt;the realtime client, the event model, ping/pong, client tools and the semantic kernel bridge are all live-verified against a real agent: i asked a voice agent "what's the status of order 1234?" and watched it call my c# method and speak the result back. audio streaming and webrtc are modeled from the docs and next on my list to exercise end to end. the library is net8.0, zero dependencies in the core, async-first with cancellation everywhere, and has an offline test suite that replays captured protocol frames so ci doesn't need network or credits.&lt;/p&gt;

&lt;p&gt;one gotcha worth documenting, because it cost me a debugging session: the websocket serves the &lt;em&gt;published&lt;/em&gt; version of your agent, not your draft. add a client tool, and until you hit publish, the live conversation still runs the old config with no tools. the agent will even narrate "let me check that" and then do nothing, because it was never told the tool exists. publish, and it works.&lt;/p&gt;

&lt;p&gt;it's on nuget as &lt;code&gt;ElevenAgents.Net&lt;/code&gt; and &lt;code&gt;ElevenAgents.Net.SemanticKernel&lt;/code&gt;, and the source is on my github. .net is a big audience for voice agents, enterprise contact-center teams especially, and right now that audience has no official path onto this platform. if you're at elevenlabs and reading this: happy to help close that gap properly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;originally published at &lt;a href="https://www.ivanjurina.com/index.php/2026/07/22/giving-a-net-app-a-voice-building-on-the-elevenlabs-agents-platform/" rel="noopener noreferrer"&gt;ivanjurina.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>csharp</category>
    </item>
    <item>
      <title>what a 2019 api client teaches you about modern .net</title>
      <dc:creator>Ivan Jurina</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:50:09 +0000</pubDate>
      <link>https://dev.to/ivan_jurina_708793b01312e/what-a-2019-api-client-teaches-you-about-modern-net-1pif</link>
      <guid>https://dev.to/ivan_jurina_708793b01312e/what-a-2019-api-client-teaches-you-about-modern-net-1pif</guid>
      <description>&lt;p&gt;i've been playing with &lt;a href="https://serpapi.com" rel="noopener noreferrer"&gt;SerpApi&lt;/a&gt; lately. nice service. one GET request and you get structured json for google, bing, maps, news, shopping and about a hundred other search surfaces. captchas and layout changes handled for you. their python and ruby stories are strong. their .net story is a time capsule.&lt;/p&gt;

&lt;p&gt;the official &lt;a href="https://github.com/serpapi/google-search-results-dotnet" rel="noopener noreferrer"&gt;&lt;code&gt;google-search-results-dotnet&lt;/code&gt;&lt;/a&gt; package was written around 2019 and it shows. &lt;code&gt;Hashtable&lt;/code&gt; parameters. Newtonsoft.Json. a synchronous api that blocks on &lt;code&gt;Task.Result&lt;/code&gt;. not a knock on SerpApi, every company has a long tail of sdks. but it makes a great case study. the distance between "working 2019 c#" and "good 2026 c#" is exactly the stuff that bites people in production. so i did two things. sent a set of prs to the official library, and built a modern client from scratch (&lt;a href="https://github.com/ivanjurina/serpapi-dotnet" rel="noopener noreferrer"&gt;serpapi-dotnet&lt;/a&gt;). here's what changed and why it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. sync-over-async is a deadlock waiting for a synchronization context
&lt;/h2&gt;

&lt;p&gt;the original core looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;queryTask&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parameter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonEnabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;queryTask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// does nothing here, by the way&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;queryTask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;two problems. first, &lt;code&gt;Task.Result&lt;/code&gt; wraps any failure in an &lt;code&gt;AggregateException&lt;/code&gt;. callers &lt;code&gt;catch (SerpApiSearchException)&lt;/code&gt; and miss. second, blocking on a task that resumes on a captured context deadlocks classic asp.net and ui apps. the &lt;code&gt;ConfigureAwait(true)&lt;/code&gt; on a task variable (not an await) is a no-op. a hint that the intent was understood but the mechanics weren't.&lt;/p&gt;

&lt;p&gt;the fix when you must keep a sync api for compatibility: &lt;code&gt;GetAwaiter().GetResult()&lt;/code&gt;, which rethrows the original exception, plus &lt;code&gt;ConfigureAwait(false)&lt;/code&gt; on every await inside the library. the real fix: expose &lt;code&gt;GetJsonAsync(CancellationToken)&lt;/code&gt; and let callers be async end to end. my pr does both without breaking the existing surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;catch (Exception ex) =&amp;gt; throw new X(ex.ToString())&lt;/code&gt; destroys the stack
&lt;/h2&gt;

&lt;p&gt;the original wraps every failure like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SerpApiSearchException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;stringifying the exception into a message means no &lt;code&gt;InnerException&lt;/code&gt;, no type to catch on, and cancellation gets swallowed into a generic error. the modern pattern: let &lt;code&gt;OperationCanceledException&lt;/code&gt; flow untouched, wrap transport errors with the original as &lt;code&gt;InnerException&lt;/code&gt;, and carry the http status code on your exception type. callers can then tell a 401 from a 429 without parsing strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;Hashtable&lt;/code&gt; to typed request with an escape hatch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Hashtable&lt;/code&gt; is pre-generics .net. the interesting design question is what replaces it, because SerpApi has dozens of engines with different parameters. a rigid typed model can't cover them all. a plain &lt;code&gt;Dictionary&amp;lt;string,string&amp;gt;&lt;/code&gt; gives up on discoverability. the answer is both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SearchRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Engine&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SearchEngine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GoogleNews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"dotnet 9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Prague, Czechia"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AdditionalParameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"so"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// anything engine-specific&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;common parameters are typed and documented. everything else passes through. same philosophy on the response side: typed accessors for &lt;code&gt;organic_results&lt;/code&gt; and friends, and a raw &lt;code&gt;JsonElement Root&lt;/code&gt; for the long tail of answer boxes and knowledge graphs.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Newtonsoft to System.Text.Json source generation
&lt;/h2&gt;

&lt;p&gt;dropping Newtonsoft isn't about fashion. with &lt;code&gt;[JsonSerializable]&lt;/code&gt; source generation you get reflection-free deserialization that's trim-safe and native-aot-compatible. the library ends up with zero external dependencies. for an sdk, every dependency you don't take is a diamond-dependency conflict your users don't have.&lt;/p&gt;

&lt;p&gt;one real-world wrinkle worth showing: SerpApi's &lt;code&gt;local_results&lt;/code&gt; is sometimes an array and sometimes an object containing a &lt;code&gt;places&lt;/code&gt; array, depending on the engine. that's the kind of thing you only learn by reading actual responses. handle it in the library so your users never see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;new HttpClient()&lt;/code&gt; per instance, or bring your own
&lt;/h2&gt;

&lt;p&gt;the 2019 client news up its own &lt;code&gt;HttpClient&lt;/code&gt;. in 2026 the library should accept one. that's what makes it work with &lt;code&gt;IHttpClientFactory&lt;/code&gt;, polly resilience pipelines, and unit tests with a fake &lt;code&gt;HttpMessageHandler&lt;/code&gt;. ship a di package with &lt;code&gt;services.AddSerpApi(...)&lt;/code&gt; and an &lt;code&gt;ISerpApiClient&lt;/code&gt; interface, and testing a search feature no longer requires mocking http at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  the payoff: a web-grounded agent in 30 lines
&lt;/h2&gt;

&lt;p&gt;the reason i care about search apis in .net at all is ai agents. every llm's knowledge stops at its training cutoff. search grounding fixes that, and everyone does it in python. with a modern client, the c# version is a Semantic Kernel plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SerpApiSearchPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ISerpApiClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;KernelFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Searches the web and returns the top results."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SearchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SearchRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrganicResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Snippet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;register it, enable automatic function calling, and the model decides when to search. the full sample, a working console agent, is in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  takeaways
&lt;/h2&gt;

&lt;p&gt;if you maintain an api client, the 2026 checklist is short. async-first with &lt;code&gt;CancellationToken&lt;/code&gt;. exceptions that preserve their cause. accept an external &lt;code&gt;HttpClient&lt;/code&gt;. source-generated &lt;code&gt;System.Text.Json&lt;/code&gt;. typed requests with an untyped escape hatch. an interface for testability. none of it is exotic. it's just the accumulated lessons of a decade of .net moving on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;the prs to the official library and the full serpapi-dotnet source are on my github. if you're at SerpApi and reading this: i'd love to help you tell this story to .net developers properly.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;originally published at &lt;a href="https://www.ivanjurina.com/index.php/2026/07/21/what-a-2019-api-client-teaches-you-about-modern-net/" rel="noopener noreferrer"&gt;ivanjurina.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>api</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
