<?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: Norbert Rosenwinkel</title>
    <description>The latest articles on DEV Community by Norbert Rosenwinkel (@norbertrosenwinkel).</description>
    <link>https://dev.to/norbertrosenwinkel</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%2F1566982%2Fee62eea2-9f08-4bcf-817f-39b55e810bbd.jpeg</url>
      <title>DEV Community: Norbert Rosenwinkel</title>
      <link>https://dev.to/norbertrosenwinkel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/norbertrosenwinkel"/>
    <language>en</language>
    <item>
      <title>I'm Building an AI Assistant in C#. I Don't Want to Rewrite Every Tool.</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:03:17 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/im-building-an-ai-assistant-in-c-i-dont-want-to-rewrite-every-tool-2b1g</link>
      <guid>https://dev.to/norbertrosenwinkel/im-building-an-ai-assistant-in-c-i-dont-want-to-rewrite-every-tool-2b1g</guid>
      <description>&lt;p&gt;I'm building an AI assistant in C#. Some of the code I want it to use already exists in Python or TypeScript.&lt;/p&gt;

&lt;p&gt;I could rewrite it. But then every improvement to the original becomes something I have to port, test and maintain. Choosing C# for the application shouldn't mean choosing C# for every capability it will ever have.&lt;/p&gt;

&lt;p&gt;Calling a script is easy enough. Deciding what happens when it hangs, which data it can ask the application for, and how to replace it without disturbing the rest of the system takes more thought.&lt;/p&gt;

&lt;p&gt;That's why I built &lt;strong&gt;WeavePort&lt;/strong&gt;, an open-source plugin runtime for .NET. I also want to keep other applications small by moving selected functionality and strategies into replaceable plugins. A department that works in Python should be able to contribute useful code without first becoming a C# team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with something worth replacing
&lt;/h2&gt;

&lt;p&gt;Suppose I want the assistant to help compare improvement proposals. One capability could score them using business priorities and risk information. The assistant could explain the results; the application would decide when the calculation is allowed to run and what data it can use. That is an intended use, not a finished assistant integration.&lt;/p&gt;

&lt;p&gt;The scoring strategy might use a Python library or rules maintained by another team. Its inputs, outputs and meaning need to stay stable when I change the implementation.&lt;/p&gt;

&lt;p&gt;This is the idea behind the repository's &lt;a href="https://github.com/yesbert/WeavePort/tree/v0.3.1/samples/DecisionRoom" rel="noopener noreferrer"&gt;Decision Room example&lt;/a&gt;. Two participants evaluate three proposals using configurable priorities. Equivalent C# and Python strategies produce the same results. Change the language and the winner stays the same; change the strategy and a different proposal can win.&lt;/p&gt;

&lt;p&gt;With the default configuration, “Automate support” wins. Increase the strategy's risk penalty and “Improve documentation” wins instead. The host still runs the same workflow.&lt;/p&gt;

&lt;p&gt;Decision Room deliberately leaves out the model. It exercises the capability I would put underneath an assistant: send proposals to a strategy, get evaluations back, and keep control of the workflow in the application. The same capability can serve a backend with no AI at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  From C# to Python and back
&lt;/h2&gt;

&lt;p&gt;Here is the call in the &lt;a href="https://github.com/yesbert/WeavePort/blob/v0.3.1/samples/DecisionRoom/Host/RoomRunner.cs" rel="noopener noreferrer"&gt;C# host&lt;/a&gt;, after it has bound the selected strategy to a worker:&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;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LocalPluginClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Evaluation&lt;/span&gt; &lt;span class="n"&gt;evaluation&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="n"&gt;CallAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DecisionRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Evaluation&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"strategy.evaluate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DecisionRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;participant&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;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request contains the participant and the room state, including the proposals. With Python selected, it reaches this function in the &lt;a href="https://github.com/yesbert/WeavePort/blob/v0.3.1/samples/DecisionRoom/Python/plugin.py" rel="noopener noreferrer"&gt;Python plugin&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strategy.evaluate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priorities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;knowledge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;knowledge.read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;benefit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;benefit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;knowledge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;RISK_MULTIPLIER&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snapshot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proposals&lt;/span&gt;&lt;span class="sh"&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;return&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;participant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;participant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;knowledge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;risks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;knowledge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pluginVersion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are excerpts from the runnable sample. Its setup supplies the binding, domain types, application registration and release constants. The SDK carries the request and result across the process boundary; C# receives an &lt;code&gt;Evaluation&lt;/code&gt;. The host then validates that evaluation before committing it to the room's state.&lt;/p&gt;

&lt;p&gt;The calculation is intentionally small enough to inspect. Version 1 uses a risk multiplier of one; version 2 uses ten. Selecting an equivalent C# strategy keeps the result. Selecting the stronger risk penalty changes it.&lt;/p&gt;

&lt;p&gt;The callback is the more interesting part. Its empty payload is deliberate: the host has already bound the plugin to a tenant and knowledge profile. The application uses that context to select the risk data. The strategy doesn't choose another customer's ID in its request.&lt;/p&gt;

&lt;p&gt;WeavePort checks whether &lt;code&gt;knowledge.read&lt;/code&gt; is granted. The application decides which records that grant permits. This lets the Python code use application-owned knowledge without carrying its own database integration.&lt;/p&gt;

&lt;p&gt;There is still integration work. An existing library needs a wrapper, its dependencies need packaging, and its result needs a meaning the caller can rely on. A typed result won't tell you whether two ranking implementations use the same scale. Those business-contract tests remain the application's responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the process a clear owner
&lt;/h2&gt;

&lt;p&gt;Python plugins run in Python processes. TypeScript plugins run through Node. The .NET host manages those workers; it doesn't execute Python inside a C# worker.&lt;/p&gt;

&lt;p&gt;For local stdio execution, requests and responses travel as framed JSON over stdin and stdout. There's no listening port to allocate for each plugin. The host handles startup, deadlines, admission limits and cleanup, and workers can be retained where the configured lifecycle allows it.&lt;/p&gt;

&lt;p&gt;Suppose a scoring call stops responding. The application needs a failed call it can handle, and the runtime needs to clean up the worker. It must also avoid silently repeating an operation that may already have changed something. A timeout cannot tell us whether an external side effect happened.&lt;/p&gt;

&lt;p&gt;I want to make those lifecycle decisions once and apply them to every strategy I add.&lt;/p&gt;

&lt;p&gt;There is a security boundary worth being precise about: &lt;strong&gt;these are trusted local plugins, not a sandbox for arbitrary uploaded code.&lt;/strong&gt; Callback permissions restrict access through the host API. A Python process still runs with its OS user's access to files and the network. Contributions from another department therefore still need review and an approved deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the capability already speaks MCP
&lt;/h2&gt;

&lt;p&gt;Sometimes the wrapper already exists: a tool comes with an MCP server. In that case, I want to use its existing interface.&lt;/p&gt;

&lt;p&gt;WeavePort can launch a trusted local MCP server, discover its tools and invoke them under the same host lifecycle and capacity budgets. One server can expose several functions. No language model has to be involved in calling them.&lt;/p&gt;

&lt;p&gt;The separate MCP sample uses text normalization as a minimal API demonstration. After binding its server, the call 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="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json&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;WeavePort.Hosting&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;McpMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&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;"normalize"&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;new&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;" hello   world "&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;The &lt;a href="https://github.com/yesbert/WeavePort/tree/v0.3.1/examples/mcp" rel="noopener noreferrer"&gt;runnable example&lt;/a&gt; uses Hosting 0.3.1 and includes binding, discovery with &lt;code&gt;McpMethods.ListTools&lt;/code&gt;, and checks for both host failures and tool errors.&lt;/p&gt;

&lt;p&gt;I still want the native plugin contract for a strategy like Decision Room, where callbacks into application-owned knowledge are part of the design. MCP is useful for consuming existing tools. It doesn't automatically replace those native callbacks or result streams, and installing a native plugin doesn't automatically expose it to an AI assistant. The application chooses what the assistant may invoke.&lt;/p&gt;

&lt;p&gt;The current MCP integration covers local stdio tool discovery and calls. Remote HTTP servers and interactive requests back into the host need a different integration. The &lt;a href="https://weaveport.dev/docs/mcp-plugins.html" rel="noopener noreferrer"&gt;MCP guide&lt;/a&gt; describes the supported revisions and limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a plugin worth the cost?
&lt;/h2&gt;

&lt;p&gt;For a small extension written entirely in C#, I would start with an ordinary interface. For functionality that needs independent deployment, several consuming applications or its own scaling policy, I would consider a service.&lt;/p&gt;

&lt;p&gt;A managed plugin fits the space I'm interested in: the application owns the workflow and wants to reuse or replace a substantial piece of behavior, possibly written in another language.&lt;/p&gt;

&lt;p&gt;Crossing a process boundary still costs something. In a local sequential echo comparison on macOS arm64 with Node 24.21.0 and the released 0.3.0 Hosting assembly, small calls over the native WeavePort protocol averaged about 0.057 ms; MCP calls averaged 0.086–0.101 ms. First calls including startup were about 59 ms and 111 ms respectively. These figures describe that test, not the latency of your tool. The &lt;a href="https://github.com/yesbert/WeavePort/blob/main/reports/release/0.3.0/README.md" rel="noopener noreferrer"&gt;benchmark report&lt;/a&gt; includes larger payloads, tail latency and the measurement setup.&lt;/p&gt;

&lt;p&gt;I'd pass a useful unit of work across that boundary, such as evaluating a set of proposals. I wouldn't split every arithmetic operation into a separate call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try changing the implementation
&lt;/h2&gt;

&lt;p&gt;The easiest way to judge the design is to run Decision Room and swap a participant's language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; v0.3.1 &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/yesbert/WeavePort.git
&lt;span class="nb"&gt;cd &lt;/span&gt;WeavePort
./scripts/decision-room.sh &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;--verify&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need the .NET SDK selected by &lt;code&gt;global.json&lt;/code&gt; and Python 3.11+ with &lt;code&gt;venv&lt;/code&gt; and &lt;code&gt;pip&lt;/code&gt;. The script builds local packages and prepares a private Python environment. This walkthrough is validated on macOS arm64; the &lt;a href="https://weaveport.dev/docs/platform-qualification.html" rel="noopener noreferrer"&gt;platform notes&lt;/a&gt; distinguish that from Windows and Linux source checks.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/yesbert/WeavePort/tree/v0.3.1/samples/DecisionRoom" rel="noopener noreferrer"&gt;walkthrough&lt;/a&gt; shows how to change the language, select a different strategy, deny a callback and recover a run. Start by changing only the language, then change the strategy. You can check for yourself which behavior belongs to the application and which belongs to the plugin.&lt;/p&gt;

&lt;p&gt;WeavePort is MIT-licensed and still pre-1.0. Its .NET packages are on NuGet; the Python and TypeScript author SDKs are currently built from the repository.&lt;/p&gt;

&lt;p&gt;What I want for my assistant is simple to describe: when I find a useful capability, most of the work should be understanding its inputs, outputs and permissions. I want the question to be “Can I use this?” long before it becomes “Do I have to rewrite this?”&lt;/p&gt;

&lt;p&gt;Where have you drawn that line in your own backend: a library, a plugin or a separate service?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain WeavePort. AI generated the article text and cover from my design goals, source code and project documentation. I reviewed the draft and directed its revisions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>architecture</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Intent-driven UIs: an Angular workbench your AI assistant operates</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Tue, 08 Sep 2026 14:14:27 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/intent-driven-uis-an-angular-workbench-your-ai-assistant-operates-5kd</link>
      <guid>https://dev.to/norbertrosenwinkel/intent-driven-uis-an-angular-workbench-your-ai-assistant-operates-5kd</guid>
      <description>&lt;p&gt;Over the last months I have heard the same wish from my customers again and again. They want to click less in the UI and have an AI assistant do more of the work. They want to say what they intend, without knowing the UI well. The assistant carries it out, asks when something is missing, and asks for approval when something should not just happen. I have started calling this intent-driven UIs, and I think a good part of business software is heading there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flw1d4mlxwwly27gknkn2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flw1d4mlxwwly27gknkn2.png" alt="The assistant panel after one sentence: three tool calls, their results, and the answer" width="464" height="1498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This tutorial shows one way to build such a thing, as inspiration for your own ideas. We build a small support inbox as a desktop-like workbench in Angular, with a list in the sidebar and a tab per ticket. Then we let an assistant work with it. One rule holds throughout, and it is the reason I am comfortable shipping something like this: the assistant can do nothing the person at the keyboard could not also do by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we build, and what we count
&lt;/h2&gt;

&lt;p&gt;The inbox has six tickets. The workflow we measure: find the ticket about the blank invoice PDF, assign it to Dana, and reply that the fix ships on Monday.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;Typed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;By hand&lt;/td&gt;
&lt;td&gt;5 (ticket, assignee select, the option, reply field, send)&lt;/td&gt;
&lt;td&gt;one reply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With the assistant&lt;/td&gt;
&lt;td&gt;2, one of them the approval of the reply&lt;/td&gt;
&lt;td&gt;one sentence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is one workflow in one small example, and I am not turning it into a percentage. It shows the shape of the thing. You need Node, the Angular CLI and an API key from &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;. The model is free there; a free key allows about fifty requests a day, and one run costs three to five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who provides what
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.ag-ui.com" rel="noopener noreferrer"&gt;AG-UI&lt;/a&gt; is an open protocol between an application and an agent. It describes the tool calling you know from function calling at OpenAI, as a format between frontend and agent. One round, a run in the protocol, goes like this: the application sends the conversation and the list of allowed tools. The agent answers with a stream of events: text for the user, tool calls, their results, and at the end "finished".&lt;/p&gt;

&lt;p&gt;Three layers meet in the code, and keeping them apart makes the rest easy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it provides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The protocol&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@ag-ui/core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;vocabulary only: the event types, the shape of a tool and of a tool result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The adapter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@loomweaver/ag-ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the logic on the workbench side: &lt;code&gt;list()&lt;/code&gt; offers the workbench's commands as tools, &lt;code&gt;receive(event)&lt;/code&gt; runs a tool call and answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent&lt;/td&gt;
&lt;td&gt;your product&lt;/td&gt;
&lt;td&gt;talks to the language model and translates in both directions; the generator writes a placeholder&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the platform does not ship a finished assistant. It ships the workbench, the adapter and a generator that writes a starting point into your project. From there on it is your code. If you want the longer version: &lt;a href="https://loomweaver.dev/ag-ui-agents/?ref=devto#whose-code-is-which" rel="noopener noreferrer"&gt;Whose code is which&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: scaffold the workbench
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ng new assistant-workbench &lt;span class="nt"&gt;--style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;css &lt;span class="nt"&gt;--ssr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;assistant-workbench

npx @loomweaver/cli init &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Assistant Workbench"&lt;/span&gt; &lt;span class="nt"&gt;--weaver&lt;/span&gt; tickets
npx @loomweaver/cli weaver &lt;span class="nt"&gt;--id&lt;/span&gt; assistant &lt;span class="nt"&gt;--agent&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; src/assistant
npm &lt;span class="nb"&gt;install

&lt;/span&gt;npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell behind these commands is LoomWeaver, an open-source plugin platform for Angular workbenches, and my own project. I use it because it brings the adapter from the table. Without it, the middle of this tutorial would be a tool registry and a dispatcher.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;init&lt;/code&gt; installs the platform into the fresh application, replaces the bootstrap wiring with a composition root, and scaffolds a first plugin, &lt;code&gt;tickets&lt;/code&gt;. The second command adds the &lt;code&gt;assistant&lt;/code&gt; plugin; it records two packages in &lt;code&gt;package.json&lt;/code&gt; and says so, which is why &lt;code&gt;npm install&lt;/code&gt; runs once more.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;npm start&lt;/code&gt; you have a workbench with panes, tabs and a command palette. The &lt;code&gt;tickets&lt;/code&gt; plugin has an example command and a view. The &lt;code&gt;assistant&lt;/code&gt; plugin has three files under &lt;code&gt;src/assistant/src/lib/agent/&lt;/code&gt;: the connection &lt;code&gt;assistant-agent.ts&lt;/code&gt;, the panel &lt;code&gt;assistant-agent-panel.ts&lt;/code&gt; and the placeholder &lt;code&gt;assistant-agent-source.ts&lt;/code&gt;. The placeholder speaks only the events, with no model and no network, so the whole path runs before you have connected anything. Click through it once before you read on.&lt;/p&gt;

&lt;p&gt;The panel sits on the right because the plugin registers it that way. &lt;code&gt;docks&lt;/code&gt; names the region, &lt;code&gt;padded&lt;/code&gt; asks for some air, because the workbench insets nothing on its own:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated · &lt;code&gt;src/assistant/src/lib/plugin/assistant.plugin.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerSurface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant.agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant.agent.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;docks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right-panel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;padded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AssistantAgentPanel&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;If you want it on the left, change &lt;code&gt;docks&lt;/code&gt;. Which regions exist is declared in &lt;code&gt;src/app/app.config.ts&lt;/code&gt;, the file where the whole product is composed; more under &lt;a href="https://loomweaver.dev/weaver/content-area/?ref=devto#reaching-the-pane-edges" rel="noopener noreferrer"&gt;Reaching the pane edges&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: two additions to the app
&lt;/h2&gt;

&lt;p&gt;The Content-Security-Policy in &lt;code&gt;src/index.html&lt;/code&gt; allows the app's own origin only. The assistant calls OpenRouter from the browser, so it is added:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/index.html&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect-src 'self' https://openrouter.ai;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the icon bar on the far left, the rail, which so far holds only the tickets icon, gets the workbench's settings gear: an entry in &lt;code&gt;app.config.ts&lt;/code&gt; that triggers the built-in command &lt;code&gt;shell.openSettings&lt;/code&gt;. Why we want it shows up when we try things out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/app/app.config.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;provideRailItems&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;workbench.settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell.openSettings&lt;/span&gt;&lt;span class="dl"&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;h2&gt;
  
  
  Step 3: turn an operation into a command
&lt;/h2&gt;

&lt;p&gt;The domain is an object with a signal holding six tickets and the operations &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;assign&lt;/code&gt;, &lt;code&gt;reply&lt;/code&gt; and &lt;code&gt;setStatus&lt;/code&gt;. It lives in &lt;code&gt;src/tickets/src/lib/tickets/ticket-store.ts&lt;/code&gt;, it is in the example, and it is deliberately dull.&lt;/p&gt;

&lt;p&gt;The interesting part is how the operations are registered. A command is the thing a button, a shortcut, a palette entry and a menu item all point at together. We replace the generator's example command with five. Here is the one that opens a ticket:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/tickets/src/lib/plugin/tickets.plugin.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.open.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.open.description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.open.number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.open.answers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;callable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;opened&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ticketActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;replies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields do the work for the assistant, and the adapter turns them into the tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;arguments&lt;/code&gt; becomes the JSON schema. The workbench checks every call against it before &lt;code&gt;run&lt;/code&gt; runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;answers&lt;/code&gt; makes the return value the answer. Without it the assistant would be blind to what it just did.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;callable: true&lt;/code&gt; opens the command to callers other than its own buttons, the assistant included. It is off by default, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The description is the text the model reads when it chooses between tools. The one for &lt;code&gt;tickets.list&lt;/code&gt; reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lists the support tickets with number, customer, subject, status and assignee. Use it to find a ticket when only its topic is known.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence turns "the ticket about the blank invoice PDF" into &lt;code&gt;T-1041&lt;/code&gt; without a search. Whether you have thought of everything is what &lt;code&gt;npx @loomweaver/cli validate-commands --dir src/tickets&lt;/code&gt; tells you. It lists every command and names where an agent would have to guess. More under &lt;a href="https://loomweaver.dev/reference/callable-commands/?ref=devto" rel="noopener noreferrer"&gt;Callable commands&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: a list in the sidebar, a tab per ticket
&lt;/h2&gt;

&lt;p&gt;This is what makes it a workbench. Instead of one page we register two surfaces: the list, in the left sidebar, and the ticket, as a tab with its own address &lt;code&gt;tickets/:number&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/tickets/src/lib/plugin/tickets.plugin.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerSurface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TicketListView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;docks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left-panel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerSurface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.ticket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.ticket.title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TicketView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;routable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets/:number&lt;/span&gt;&lt;span class="dl"&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;Opening a ticket means opening a tab at that path. The click in the list and the &lt;code&gt;tickets.open&lt;/code&gt; command go through the same function, so the two cannot drift apart:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New · &lt;code&gt;src/tickets/src/lib/plugin/tickets-actions.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;Ticket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ticketStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;openContentTab&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`tickets/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;titleIsLiteral&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ticket&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;A click opens a preview that the next click reuses, a double click keeps the tab. The ticket view in &lt;code&gt;src/tickets/src/lib/views/ticket-view.ts&lt;/code&gt; reads its number from the route; its buttons call the same store functions the commands call. There is no second code path for the assistant. More under &lt;a href="https://loomweaver.dev/weaver/content-area/?ref=devto#opening-tabs-from-code" rel="noopener noreferrer"&gt;Opening tabs from code&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: human in the loop, which command needs a person
&lt;/h2&gt;

&lt;p&gt;The generated code has a checkpoint that runs before every tool call the assistant makes. This is the human-in-the-loop moment: for certain commands it shows the person at the keyboard a confirmation dialog. If they say no, the command does not run, and the assistant learns that it was declined. We change only one line: which commands need that confirmation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/assistant/src/lib/agent/assistant-agent.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONSEQUENTIAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tickets.reply&lt;/span&gt;&lt;span class="dl"&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn92srvjffxosk58vfrpp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn92srvjffxosk58vfrpp.png" alt="The confirmation dialog: " width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A test for what belongs in that set: would you want it to happen while you were looking away? Sending, deleting, publishing, yes. Opening and listing, no, because asking about everything trains people to click the question away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: the agent
&lt;/h2&gt;

&lt;p&gt;We replace &lt;code&gt;assistant-agent-source.ts&lt;/code&gt; entirely but keep the placeholder's shape: a function that gives off event after event while it works, an async generator in TypeScript. The panel hands it the request:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New · &lt;code&gt;src/assistant/src/lib/agent/assistant-agent-source.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AgentRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BaseEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToolMessage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;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;tools&lt;/code&gt; are the tools from &lt;code&gt;list()&lt;/code&gt;, asked for again on every round. &lt;code&gt;receive&lt;/code&gt; is the door into the workbench: every event goes in there, and for a completed tool call the result comes back. The loop below is the usual tool-calling loop. Ask the model. If it wants to call tools, run them and return the results. Repeat until it answers in words.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fetchLike&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AsyncGenerator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BaseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RUN_STARTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_ROUNDS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchLike&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;say&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_calls&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&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="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;yield&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RUN_FINISHED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RUN_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failure&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;complete&lt;/code&gt; is one HTTP request to OpenRouter, in the same format as OpenAI's: the conversation plus the tools, translated into its function format. One detail: function names there may not contain dots, so &lt;code&gt;tickets.open&lt;/code&gt; travels as &lt;code&gt;tickets__open&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;relay&lt;/code&gt; translates back. One tool call from the model becomes the protocol's three events, start, arguments, end, and each goes to &lt;code&gt;receive&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOOL_CALL_START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toolCallId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;toolCallName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;commandId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOOL_CALL_ARGS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toolCallId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TOOL_CALL_END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toolCallId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the end event, the adapter first runs the human-in-the-loop check from step 5, then the command. Back comes the answer for the model: the return value, a refusal or a failure, worded so the model can tell "you may not" from "it broke". Nowhere here is a list of tools or a switch that routes calls; the adapter behind &lt;code&gt;receive&lt;/code&gt; does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: the panel
&lt;/h2&gt;

&lt;p&gt;The generator also gave the assistant an example command with a shortcut, a view with a route and a rail item, so that the rail shows something on the first serve. They go now, and the test that pinned the example command goes with them, because the panel is everything this plugin contributes.&lt;/p&gt;

&lt;p&gt;The generated panel offered one button per tool. We give it a text field for the sentence and a field for the key, which stays in local storage (&lt;code&gt;openrouter-key.ts&lt;/code&gt;) and goes to OpenRouter only. The send method builds the request from step 6:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changed · &lt;code&gt;src/assistant/src/lib/agent/assistant-agent-panel.ts&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`run-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BaseEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;The model is one constant, &lt;code&gt;dots-studio/dots-3-note-preview:free&lt;/code&gt;. Any free model on OpenRouter with tool calling will do, and you will need another one sooner or later, because the free ones come and go; the &lt;a href="https://openrouter.ai/models?fmt=cards&amp;amp;supported_parameters=tools&amp;amp;max_price=0" rel="noopener noreferrer"&gt;list is filtered&lt;/a&gt; and the example says so where the constant is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Paste the key into the panel and type:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Open the ticket about the blank invoice PDF, assign it to Dana and reply that the fix ships on Monday.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The panel shows what the model does: list the tickets, find &lt;code&gt;T-1041&lt;/code&gt;, assign it, and at the reply the workbench asks you. Say yes, and the ticket's tab comes to the front, with Dana and the reply. Say no, and the workbench was never asked. For me the whole thing took under ten seconds. Sometimes the model opens the ticket first, sometimes not; the order belongs to the model, only the boundary is yours.&lt;/p&gt;

&lt;p&gt;Then take something away from it. Gear, Permissions, switch off "Run actions other plugins added" under Assistant, close the dialog, click into the input: "0 tools offered".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdj58ln4by599s3a9nl0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdj58ln4by599s3a9nl0.png" alt="The assistant's permissions: " width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ask it something now, and it can do nothing. We did not build that. The adapter asks on every run what the plugin may reach, and the workbench answers with what you left it. More under &lt;a href="https://loomweaver.dev/distribution/capabilities/?ref=devto#the-permissions-section" rel="noopener noreferrer"&gt;Permissions&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary, and what changes for production
&lt;/h2&gt;

&lt;p&gt;The checkpoint from step 5 can only say no, never allow more. A yes only means the product has no objection. After that the workbench checks what it always checks: whether the command exists, whether it is callable, whether the signed-in person would be allowed to run it. And every refusal reads the same to the model, so an assistant cannot learn what is installed by asking. Details under &lt;a href="https://loomweaver.dev/reference/agent-tools/?ref=devto#what-the-agent-never-learns" rel="noopener noreferrer"&gt;What the agent never learns&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For production, nothing in the workbench changes. The key in the browser is fine for a tutorial and not for a product. There the agent runs on your own server, in whatever stack, and the ready-made client &lt;code&gt;@ag-ui/client&lt;/code&gt; fetches its events into the same &lt;code&gt;receive()&lt;/code&gt; loop. Panel, connection and commands stay as they are. The frontend does not care where the model lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;The example lives in the LoomWeaver repository, and you can fetch it on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx degit yesbert/loomweaver/examples/assistant-workbench#tutorial-intent-driven assistant-workbench
&lt;span class="nb"&gt;cd &lt;/span&gt;assistant-workbench &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://demo.loomweaver.dev/?ref=devto" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; has an assistant panel too, and the &lt;a href="https://loomweaver.dev/ag-ui-agents/?ref=devto" rel="noopener noreferrer"&gt;guide to AG-UI agents&lt;/a&gt; covers what is missing here.&lt;/p&gt;

&lt;p&gt;I would be curious what intent-driven UIs look like in your domain, and where the line between "just do it" and "ask me first" falls for you. That line is, I think, the actual design work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>tutorial</category>
      <category>angular</category>
    </item>
    <item>
      <title>How AI changed the way I build software, and why I ended up building an open source shell for Angular</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:52:38 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/how-ai-changed-the-way-i-build-software-and-why-i-ended-up-building-an-open-source-shell-for-4i7j</link>
      <guid>https://dev.to/norbertrosenwinkel/how-ai-changed-the-way-i-build-software-and-why-i-ended-up-building-an-open-source-shell-for-4i7j</guid>
      <description>&lt;p&gt;Up front: this is my own project, so I'm not exactly neutral here ;-)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'm coming from
&lt;/h2&gt;

&lt;p&gt;I work completely differently than I did two or three years ago. For most of my career I wanted to write pretty much every line myself, and I was a bit proud of that. That has changed a lot. Instead of programming I now mostly write specifications and review what the AI generates. On the one hand that's great, I can turn new ideas into working software much faster than before. On the other hand there's the risk of stepping into the same traps with AI-generated code again and again.&lt;/p&gt;

&lt;p&gt;And that's where I noticed something. Every time I started a new project, I found myself explaining the same things to the AI. This goes into a plugin. That stays out of the core. No domain logic in the shell. Please don't invent a third way of doing tabs. The AI would nod, generate something that looked right, and two days later I'd find a slightly different version of the same sidebar with a slightly different bug.&lt;/p&gt;

&lt;p&gt;There are things I really don't want to explain over and over. A good, preferably deterministic base is getting more important, not less. I don't want to explain proven architectures from scratch every time. I'd rather build on established solutions where I can, ones the AI understands and can just use.&lt;/p&gt;

&lt;p&gt;So for my new projects I built exactly that, and put it on GitHub as open source. Why Angular? Well, simply because I think it's a great framework and I've had a lot of good experiences with it over the last 10 years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is, and what it isn't
&lt;/h2&gt;

&lt;p&gt;LoomWeaver is a workbench shell for Angular. Not a component library. Think of the frame VS Code gives you: a rail on the left, sidebars, a top bar, a status bar, and in the middle tabs and panes you can split and drag around. That frame is what most workbench-style products build themselves, every time, slightly differently. LoomWeaver gives you that frame, and your own domain moves in as plugins.&lt;/p&gt;

&lt;p&gt;The core contains zero domain logic. Even my own product UI goes through the same plugin contract a third party would use. That sounds like a purity thing, but it's actually what makes the AI part work, I'll get to that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F29j4om3dhyv6kq5z05bg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F29j4om3dhyv6kq5z05bg.gif" alt="A 27 second tour: command palette, splitting a pane, a sandboxed non-Angular plugin, and a plugin re-skinning the whole app" width="700" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Twenty-seven seconds, no cuts. The rail, the panes, the palette and the status bar are the platform's. Everything inside them comes from plugins, the theme at the end included. There's a &lt;a href="https://loomweaver.dev" rel="noopener noreferrer"&gt;better quality version on the site&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's in the box, out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tab groups with drag-to-split panes, and any tab pops out into its own window&lt;/li&gt;
&lt;li&gt;a command palette (⌘K) and quick open (⌘P), with preview tabs and pinning&lt;/li&gt;
&lt;li&gt;named workspaces that remember their own arrangement&lt;/li&gt;
&lt;li&gt;theming from semantic tokens: bring Tailwind, bring Bootstrap, bring nothing&lt;/li&gt;
&lt;li&gt;i18n with namespaced composition, so two plugins can never collide on a key&lt;/li&gt;
&lt;li&gt;WCAG 2.1 AA accessibility the host meets and your plugins inherit&lt;/li&gt;
&lt;li&gt;an installable PWA with an update flow that actually works&lt;/li&gt;
&lt;li&gt;live state sync across windows and tabs&lt;/li&gt;
&lt;li&gt;save, discard or cancel when an editor closes with unsaved work&lt;/li&gt;
&lt;li&gt;auth-aware chrome: contributions declare the access they need, the shell reacts&lt;/li&gt;
&lt;li&gt;a plugin system with three trust levels, from your own in-process code to sandboxed iframes to plugins installed at runtime from a catalog&lt;/li&gt;
&lt;li&gt;an AG-UI adapter, so any agent speaking that standard can run your commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it ships no server. Settings, session and secrets are ports you implement against your own backend. That's a decision, not a gap. I already have a backend, and so do you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five minutes to a running product
&lt;/h2&gt;

&lt;p&gt;This is the whole setup, against a fresh Angular app or the one you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a fresh Angular app, or the one you already have&lt;/span&gt;
ng new my-studio &lt;span class="nt"&gt;--style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;css &lt;span class="nt"&gt;--ssr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;my-studio

&lt;span class="c"&gt;# the platform&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @loomweaver/shell @loomweaver/plugin-sdk @angular/cdk @jsverse/transloco &lt;span class="se"&gt;\&lt;/span&gt;
  @ng-icons/heroicons &lt;span class="se"&gt;\&lt;/span&gt;
  @angular/service-worker@&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"require('@angular/core/package.json').version"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; tailwindcss @tailwindcss/postcss @tailwindcss/typography

&lt;span class="c"&gt;# your product, and a first plugin already contributing to it&lt;/span&gt;
npx @loomweaver/cli distribution &lt;span class="nt"&gt;--name&lt;/span&gt; my-studio &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"My Studio"&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
npx @loomweaver/cli weaver &lt;span class="nt"&gt;--id&lt;/span&gt; notes &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="nt"&gt;--shortcut&lt;/span&gt; &lt;span class="s1"&gt;'mod+shift+n'&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; src/notes

ng serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scaffold writes eleven files and deletes nothing. Your &lt;code&gt;README.md&lt;/code&gt; stays yours. And a plugin, which I call a weaver because, well, it weaves on a loom, is a manifest and one &lt;code&gt;activate()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@loomweaver/plugin-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notesWeaver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contributions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;host&lt;/span&gt;&lt;span class="dl"&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;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ctx.registerSurface / registerCommand / registerBarItem / registerRailItem&lt;/span&gt;
    &lt;span class="c1"&gt;// ctx.ui.*  (dialogs, toasts, settings)&lt;/span&gt;
    &lt;span class="c1"&gt;// ctx.host.* (version, update)&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;Capabilities are default-deny. A weaver declares what it needs, the distribution grants it, and everything else is simply not there. What a weaver hands the workbench are ordinary standalone Angular components against the router you already use. Nothing about how you write Angular changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that's actually about AI
&lt;/h2&gt;

&lt;p&gt;This is the bit I care about most, and it's three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The contract is written down, as specs.&lt;/strong&gt; Everything the platform guarantees lives as one specification per capability in the repo. Not as prose in a wiki, as the thing the tests are derived from. When I change behaviour, the spec changes first. That's what I meant by a deterministic base: my AI assistant doesn't have to guess how tabs work, and neither do I.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your AI can read it directly.&lt;/strong&gt; There's an &lt;code&gt;llms.txt&lt;/code&gt; and an &lt;code&gt;llms-full.txt&lt;/code&gt; on the site, and there's an MCP server. Drop this into your assistant's config and it can scaffold weavers and distributions in your own repository, with the actual contract in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"loomweaver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@loomweaver/mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An agent can drive your product, and only as far as the user could.&lt;/strong&gt; The workbench speaks &lt;a href="https://docs.ag-ui.com" rel="noopener noreferrer"&gt;AG-UI&lt;/a&gt;, the open protocol between a user-facing app and an agentic backend. You don't describe your actions a second time. A command you register with &lt;code&gt;callable: true&lt;/code&gt; is a tool an agent is offered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoices.export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Export invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Export the selected invoices as CSV&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;choice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quarter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;year&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;callable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;exportInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&lt;/span&gt;&lt;span class="dl"&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;A call from an agent comes back through the same seam a button, a shortcut and the command palette already run through. Permissions and access gating aren't checked again for agents, because they were never bypassed in the first place. If you want a confirmation before the expensive ones, there's a &lt;code&gt;before&lt;/code&gt; hook, and it can only narrow, never widen.&lt;/p&gt;

&lt;h2&gt;
  
  
  No lock-in on CSS
&lt;/h2&gt;

&lt;p&gt;The base layout is built with Tailwind, but you don't need it in your project. The shell ships as a pre-compiled stylesheet, the design tokens are plain CSS variables, so you can put whatever CSS framework you like next to it in its own cascade layer. For Bootstrap there's even a preset that maps the tokens onto Bootstrap's own variables, so both worlds share one palette:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @loomweaver/cli theme &lt;span class="nt"&gt;--preset&lt;/span&gt; bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plugins don't have to be Angular either. A sandboxed weaver runs in its own iframe with an opaque origin, no reach into your DOM, and you can write its body in React, Vue, Svelte or plain JS. The tour above shows one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest bits
&lt;/h2&gt;

&lt;p&gt;It's young. It targets Angular 22 and Node 24, nothing older. It's for products that are a workbench, several things open at once, a surface other people extend. It is not for a site of plain pages, and it is not a component library, you bring your own buttons. And yes, a lot of the code was AI-assisted. What gets reviewed, tested and released is the contract in the specs, plus a nightly end-to-end suite that actually clicks through the workbench.&lt;/p&gt;

&lt;p&gt;I'd be really happy about constructive feedback, the harsher the better. Docs and the live demo are at &lt;a href="https://loomweaver.dev" rel="noopener noreferrer"&gt;loomweaver.dev&lt;/a&gt;, the code is at &lt;a href="https://github.com/yesbert/loomweaver" rel="noopener noreferrer"&gt;github.com/yesbert/loomweaver&lt;/a&gt;, Apache 2.0. And if you feel like it, you're very welcome to get involved.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>opensource</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>You can't delete an event. GDPR says you must. Crypto-shredding is the truce.</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Wed, 03 Jun 2026 15:24:11 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/you-cant-delete-an-event-gdpr-says-you-must-crypto-shredding-is-the-truce-26ak</link>
      <guid>https://dev.to/norbertrosenwinkel/you-cant-delete-an-event-gdpr-says-you-must-crypto-shredding-is-the-truce-26ak</guid>
      <description>&lt;h2&gt;
  
  
  Two rules that can't both be true
&lt;/h2&gt;

&lt;p&gt;Event sourcing has one rule: &lt;strong&gt;you never delete.&lt;/strong&gt; You append. The log is the source of truth, and rewriting history is the cardinal sin.&lt;/p&gt;

&lt;p&gt;GDPR Article 17 has one rule too: &lt;strong&gt;when a user asks, you erase their personal data.&lt;/strong&gt; Not "hide it," not "flag it deleted" — erase it, everywhere, including backups.&lt;/p&gt;

&lt;p&gt;Put an event-sourced system in front of a privacy regulator and those two rules collide head-on. The user's name, email, and address are baked into &lt;code&gt;CustomerRegistered&lt;/code&gt;, &lt;code&gt;AddressChanged&lt;/code&gt;, &lt;code&gt;OrderPlaced&lt;/code&gt; — dozens of immutable events, replicated to read models, snapshotted, and sitting in every nightly backup you've ever taken.&lt;/p&gt;

&lt;p&gt;"Just delete the events" breaks event sourcing. "Never delete" breaks the law. Most teams discover this tension &lt;em&gt;after&lt;/em&gt; they've committed to append-only.&lt;/p&gt;

&lt;p&gt;A word on why this isn't academic for me. I build from Germany. Article 17 is EU law — the GDPR, or DSGVO as we call it here — not a German invention, but Germany enforces it about as hard as anywhere in Europe: regional data-protection authorities that issue real fines, and "we were careful" has never been a defense that held up. That pressure is exactly why I wanted erasure to fall out of the architecture instead of being a promise I make to an auditor and then pray I can keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "delete the row" doesn't actually erase anything
&lt;/h2&gt;

&lt;p&gt;Say you give in and hard-delete the events for one user. You've still got their data in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every &lt;strong&gt;read-model projection&lt;/strong&gt; rebuilt from those events,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;snapshot&lt;/strong&gt; that rolled them up,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;backup&lt;/strong&gt; taken before the deletion,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;replica&lt;/strong&gt; and every &lt;strong&gt;export&lt;/strong&gt; that already left the building.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chasing personal data across all of those, provably, on a 30-day regulatory clock, is a nightmare — and a single missed backup tape means you didn't comply. Physical deletion doesn't scale to a system designed to keep everything forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crypto-shredding: delete the key, not the data
&lt;/h2&gt;

&lt;p&gt;The trick is to stop trying to delete the data and instead delete the &lt;em&gt;ability to read it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Encrypt each subject's personal data under a key that belongs &lt;strong&gt;only to that subject&lt;/strong&gt;. Keep the ciphertext wherever it lands — events, snapshots, backups, replicas. When the erasure request comes in, you don't hunt down the data. You &lt;strong&gt;destroy the one key.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The instant that key is gone, every copy of that ciphertext — including the ones on backup tapes you can't even reach — turns into undecryptable noise. The bytes still exist; they're just permanently meaningless. That's &lt;strong&gt;crypto-shredding&lt;/strong&gt;, and it's what makes Article 17 erasure architecturally sound instead of a manual scavenger hunt.&lt;/p&gt;

&lt;p&gt;The data model becomes: &lt;em&gt;plaintext is derived, ciphertext is permanent, and recoverability is a property of a key you control.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xydnk286nfp7s7j0rk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xydnk286nfp7s7j0rk0.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tenant-aware twist (so a leak doesn't cascade)
&lt;/h2&gt;

&lt;p&gt;There's a second property worth getting for free here. If you bind the ciphertext to &lt;em&gt;whose&lt;/em&gt; data it is, a leaked key from one tenant can't unlock another's.&lt;/p&gt;

&lt;p&gt;AES-GCM takes &lt;strong&gt;associated data (AAD)&lt;/strong&gt; — bytes that aren't encrypted but &lt;em&gt;must match&lt;/em&gt; at decryption time or the authentication tag fails. Fold the tenant id into the AAD, and a ciphertext encrypted for tenant A simply won't decrypt under tenant B's context — &lt;em&gt;even if the attacker has the right key bytes.&lt;/em&gt; The tenant binding is mathematical, not a &lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; you hope nobody forgets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkk2lbxhxvwrhet8zsex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkk2lbxhxvwrhet8zsex.png" alt=" " width="800" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in code
&lt;/h2&gt;

&lt;p&gt;This is the model &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt; builds in (a .NET event-sourcing stack), but the technique is framework-agnostic — the pieces are a key store, an AES-GCM encryptor, and a key you can destroy.&lt;/p&gt;

&lt;p&gt;Mark the sensitive fields:&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;record&lt;/span&gt; &lt;span class="nc"&gt;CustomerRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EncryptData&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;FullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EncryptData&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;Email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encryption happens at the &lt;strong&gt;serialization boundary&lt;/strong&gt; — when the event is written to the store or the bus, not in memory. Your handler reads &lt;code&gt;customer.Email&lt;/code&gt; and gets plaintext; the bytes at rest are sealed.&lt;/p&gt;

&lt;p&gt;A key is addressed by a &lt;strong&gt;scope&lt;/strong&gt; — a sensitivity level optionally narrowed to a tenant and/or user:&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;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;KeyScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataSensitivityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantScoped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The store hands out a versioned, KEK-wrapped data-encryption key per scope (never plaintext at rest). Rotation adds a version and keeps old ciphertext readable. And the erasure request — the whole point — is one call:&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="n"&gt;keyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EraseScopeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// every ciphertext under this scope is now permanently undecryptable — backups included&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqla8lb9bwe0tgisonmmw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqla8lb9bwe0tgisonmmw.png" alt=" " width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For larger payloads (attachments, exports) the same scope + a &lt;code&gt;purpose&lt;/code&gt; label bind a whole stream:&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;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sealedStream&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;blobEncryptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EncryptAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;plainStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"attachment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it costs at runtime
&lt;/h2&gt;

&lt;p&gt;The question that always follows "encrypt every sensitive field" is "what does that do to throughput?" Measured: sealing a field with AES-GCM runs around &lt;strong&gt;4 µs per object&lt;/strong&gt; — a few hundred thousand encrypt operations a second on a single core of a &lt;em&gt;fanless&lt;/em&gt; MacBook Air M4, and it parallelizes across them. The number worth knowing is what an object with &lt;em&gt;no&lt;/em&gt; &lt;code&gt;[EncryptData]&lt;/code&gt; fields costs: about &lt;strong&gt;45 nanoseconds&lt;/strong&gt; over plain &lt;code&gt;System.Text.Json&lt;/code&gt;. Routing everything through the secure serializer is essentially free; you only pay for the fields you actually mark. Encryption stays a per-field decision, not a per-system tax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits (because there always are some)
&lt;/h2&gt;

&lt;p&gt;Crypto-shredding is genuinely strong, but it is not magic, and anyone selling it as magic is wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It only shreds what stayed encrypted.&lt;/strong&gt; Personal data that escaped the encrypted boundary — written to a log line, an analytics event, a search index, a CSV someone emailed — is &lt;em&gt;not&lt;/em&gt; under the key and is &lt;em&gt;not&lt;/em&gt; shredded. The boundary is only as good as your discipline about what crosses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shreds recoverability, not the bytes.&lt;/strong&gt; If your threat model requires provable physical destruction, crypto-shredding doesn't give you that — it gives you computational irreversibility, which is what regulators actually accept, but know the difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A key extracted before the shred is a key forever.&lt;/strong&gt; If an attacker copied the DEK while it was live, destroying it later doesn't help for the data they already grabbed. Crypto-shredding protects against &lt;em&gt;future&lt;/em&gt; reads of &lt;em&gt;retained&lt;/em&gt; ciphertext, not past compromise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key custody is now the whole ballgame.&lt;/strong&gt; You've moved the problem from "delete data everywhere" to "manage and destroy keys reliably." That's a better problem — it's small and centralized — but it's a real one. The data-encryption keys should themselves be wrapped by a master key (KEK) you keep in an HSM / KMS / vault, and your keystore backups need their own erasure story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name those, and crypto-shredding goes from a compliance checkbox to an actual control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this earns its keep
&lt;/h2&gt;

&lt;p&gt;Beyond GDPR Article 17, the same per-subject-key model underwrites a lot of the compliance surface teams dread:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SOC 2 / ISO 27001&lt;/strong&gt; — demonstrable data-isolation and key-lifecycle controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HIPAA&lt;/strong&gt; — per-patient cryptographic separation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant SaaS&lt;/strong&gt; — a leaked row from one tenant is useless against another, by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It turns "we'll be careful" into "the storage layer is careful by default."&lt;/p&gt;




&lt;p&gt;The hard part of event sourcing was never the append. It's the two things append-only quietly makes harder — &lt;em&gt;proving nobody rewrote history&lt;/em&gt;, and &lt;em&gt;erasing someone who has the right to be forgotten.&lt;/em&gt; The first is hash-chaining (I wrote that one up separately). The second is crypto-shredding, above.&lt;/p&gt;

&lt;p&gt;The technique drops into any append-only store. If you want it wired — &lt;code&gt;[EncryptData]&lt;/code&gt;, scoped keys, one-call erasure — it's in &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, the .NET stack I maintain; zero-dependency samples in the repo, docs at &lt;a href="https://docs.stratara.tech" rel="noopener noreferrer"&gt;https://docs.stratara.tech&lt;/a&gt;. Source-available under MIT License.&lt;/p&gt;

&lt;p&gt;Happy to get torn apart in the comments — especially on the limits. And if the approach earns a place in your toolbox, a star on the repo helps the next person fighting the append-only-vs-Article-17 problem find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>security</category>
      <category>eventsourcing</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>AI doesn't fail because the model is bad. It fails because there's nothing underneath it</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Sun, 31 May 2026 13:17:48 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/ai-doesnt-fail-because-the-model-is-bad-it-fails-because-theres-nothing-underneath-it-1p1g</link>
      <guid>https://dev.to/norbertrosenwinkel/ai-doesnt-fail-because-the-model-is-bad-it-fails-because-theres-nothing-underneath-it-1p1g</guid>
      <description>&lt;p&gt;There's a question every system runs into the moment it goes to production and starts doing real things: &lt;strong&gt;what exactly happened, in what order, against what data — and can you prove it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI is just making that question very loud right now. Picture the case that gets more likely with every tool-using agent: a support agent — not a human, an LLM with tool access — cancels a subscription, issues a refund, fires off three follow-up emails. The next day the customer says: I never cancelled. Now answer the question above.&lt;/p&gt;

&lt;p&gt;In most codebases the honest answer is: you can see the &lt;em&gt;current&lt;/em&gt; state of the database (subscription cancelled), but not the path that got it there. A few log lines the next refactor will overwrite. No reliable record of &lt;em&gt;which&lt;/em&gt; actor acted on behalf of &lt;em&gt;which&lt;/em&gt; customer. And undoing it means hand-writing a correction and hoping you catch every side effect.&lt;/p&gt;

&lt;p&gt;That's not a model problem. GPT wasn't "wrong." The problem sits one layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI part is now the easy part
&lt;/h2&gt;

&lt;p&gt;Two years ago the model was the hard part. Today you wire up an agent that calls tools, makes plans, and takes actions in ten minutes. The demo looks fantastic — and that's exactly the trap. A demo doesn't move anything real. The moment something &lt;strong&gt;touches real state in production&lt;/strong&gt;, the problems no better prompt will solve show up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt;: what was the situation &lt;em&gt;when&lt;/em&gt; the decision was made? A CRUD table only knows &lt;em&gt;now&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History&lt;/strong&gt;: which steps led to the outcome? Without a record — gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution&lt;/strong&gt;: who or what acted, and authorized by what?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversibility&lt;/strong&gt;: a wrong action — how do you take it back &lt;em&gt;cleanly&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust&lt;/strong&gt;: can someone quietly rewrite the record afterwards?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  This isn't actually an AI problem
&lt;/h2&gt;

&lt;p&gt;And here's the part that matters to me more than all the agent hype: &lt;strong&gt;none of this is new, and none of it is AI-specific.&lt;/strong&gt; A webhook that changes a record at night; a batch job; an admin clicking the wrong button under pressure; a second service writing in over the message queue — they all raise the exact same five questions. State, history, attribution, reversibility, trust are the properties of &lt;em&gt;good software&lt;/em&gt;, full stop.&lt;/p&gt;

&lt;p&gt;AI did just one thing: it took away your excuse. As long as the only actor was a human managing one click a minute, you could muddle through — grep the logs, guess when in doubt. An autonomous agent firing a hundred actions a second doesn't allow muddling through. It just makes the gap that was always there impossible to miss.&lt;/p&gt;

&lt;p&gt;That was the idea behind what I build from the start: &lt;strong&gt;not an AI framework, but a foundation for good software.&lt;/strong&gt; That an agent can run on top is an option — a very current one — but not the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a foundation like that stands on
&lt;/h2&gt;

&lt;p&gt;Nobody &lt;em&gt;needs&lt;/em&gt; a particular pattern to ship a feature — I'd never claim that. But the moment a system seriously manages state, no matter who touches it, these three decisions stop being academic:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A system of record instead of a snapshot — CQRS + Event Sourcing.&lt;/strong&gt;&lt;br&gt;
Overwriting fields means throwing the past away. Make each change an immutable event instead, and history is &lt;em&gt;built in&lt;/em&gt;, not bolted on. You replay the stream and reconstruct exactly what happened — whether an agent, a job, or a human triggered it. "Undo" becomes a domain-level compensation event instead of a panicked &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Structure that keeps the volatile at the edge — Clean Architecture + Vertical Slices.&lt;/strong&gt;&lt;br&gt;
Every integration point is restless — a third-party API, a payment provider, and yes, AI code with its weekly-changing prompts and models. Let that seep into the domain core and it rots it. A clean core in the middle, the volatile as an outer layer — and each capability as a vertical slice (command → handler → events → projection). New things get added without tearing open five layers, and without the new toppling the existing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Trust as a property, not a hope — audit, tamper-evidence, encryption, validation, authorization.&lt;/strong&gt;&lt;br&gt;
Any actor allowed to change state needs guardrails &lt;em&gt;the system&lt;/em&gt; enforces. Every action runs as a command that's validated, authorized, and audited — and the audit records &lt;em&gt;who triggered it&lt;/em&gt; separately from &lt;em&gt;whose data it touched&lt;/em&gt;, because with an agent acting on a customer's behalf those are two different identities. The record itself is tamper-evident (hash-chained — rewrite a row after the fact and it shows). And personal data stays encrypted per subject and erasable — because neither "the AI did it" nor "that's just the nightly job" is a free pass against GDPR.&lt;/p&gt;

&lt;p&gt;Only together do the three give an answer to "what happened — and can you prove it?" that an auditor will believe. That holds for your agent. It holds just as much for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I have to be honest (the limits)
&lt;/h2&gt;

&lt;p&gt;So this doesn't turn into a list of miracle cures: a foundation doesn't make your software &lt;em&gt;correct&lt;/em&gt;. It makes what it does &lt;strong&gt;provable, replayable, and containable&lt;/strong&gt; — which is a different thing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It records &lt;strong&gt;what&lt;/strong&gt; happened, not &lt;strong&gt;why&lt;/strong&gt; a model decided it. An AI black box stays a black box; you log inputs, actions, results — not the causality inside.&lt;/li&gt;
&lt;li&gt;It doesn't &lt;strong&gt;prevent&lt;/strong&gt; a dumb action. It makes it visible and handleable via compensation — but the email that already went out, no replay brings back.&lt;/li&gt;
&lt;li&gt;It trades "delete data everywhere" for "manage keys and permissions cleanly." Honestly: a better problem — but still a problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Build the architecture, not the plumbing
&lt;/h2&gt;

&lt;p&gt;And here's the catch: building this foundation &lt;em&gt;yourself&lt;/em&gt; — event store, command pipeline, outbox, projections, audit, encryption, and all the wiring in between — eats months before you've shipped a single feature. So most teams skip it, ship on CRUD, and hit the can-you-prove-it question later at full force — no AI required.&lt;/p&gt;

&lt;p&gt;I didn't want to pay those months again for every project. So I built the foundation once, cleanly, and lifted it out of our own products: &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, a .NET 10 stack that brings exactly this — CQRS, event sourcing, mediator, outbox, sagas, projections, identity, plus tamper-evident streams and tenant-bound encryption, lockstep-versioned across 22 NuGet packages, à la carte. The idea behind it isn't "AI platform" — it's simply that you build the &lt;em&gt;architecture&lt;/em&gt; of your application, not the plumbing under it. That an agent fits safely on top is a nice side effect of the foundation being right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fast enough that you actually leave it on
&lt;/h2&gt;

&lt;p&gt;The whole premise was an actor firing a hundred actions a second — so the foundation has to keep up, not buckle under its own audit guarantees. And here's the failure mode nobody admits to: the guarantees are real &lt;em&gt;and&lt;/em&gt; slow, so the first time a load test goes red, someone quietly switches them off. Audit sampling drops to one-in-ten. The projection rebuild moves to a nightly cron instead of running live. The thing that was supposed to make the system provable becomes the thing you disable to hit your p99. That's not a foundation — that's a feature flag waiting to be turned off.&lt;/p&gt;

&lt;p&gt;So the hot paths don't use reflection. Replaying a stream means calling an &lt;code&gt;Apply&lt;/code&gt; method for every event, and the naive way — &lt;code&gt;MethodInfo.Invoke&lt;/code&gt; per event — is exactly the cost that pushes people to cut corners. Instead, each apply-method, projection handler, and constructor is compiled &lt;strong&gt;once&lt;/strong&gt; into a strongly-typed delegate (&lt;code&gt;Expression.Lambda(...).Compile()&lt;/code&gt;) and cached. After that first compile it's a direct call, not a lookup. A compiled property write clocks about &lt;strong&gt;13× faster&lt;/strong&gt; than the reflection equivalent on this machine — and because it runs per event, that gap compounds linearly with stream length.&lt;/p&gt;

&lt;p&gt;The payoff shows up where it matters, in a full replay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Events replayed&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.11 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;1.13 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;11.6 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;million events in ~12 ms&lt;/strong&gt; — and, the part I like more, at a &lt;strong&gt;constant 64 bytes&lt;/strong&gt; no matter how long the stream. Replaying a whole history hands the garbage collector essentially nothing to chase. The audit trail you keep for the auditor is the same data structure you replay in single-digit milliseconds for the app. You don't get to pick between provable and fast; you get both or you get neither, and here it's both.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Measured with BenchmarkDotNet on a fanless MacBook Air M4. Read the numbers as ratios, not server absolutes — a cooled box with real airflow moves the absolutes, not the shape. The benchmark project ships in the repo; &lt;code&gt;dotnet run -c Release&lt;/code&gt; reproduces it.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale by adding boxes, not by rewriting code
&lt;/h2&gt;

&lt;p&gt;Speed on one core is table stakes. The harder promise — and the one event sourcing usually breaks — is what happens when the hundred-actions-a-second actually arrive, from many actors, all at once.&lt;/p&gt;

&lt;p&gt;The textbook trap is the global lock. To keep one aggregate's events in order, the easy implementation serialises &lt;em&gt;every&lt;/em&gt; write, and now your throughput ceiling is one core no matter how many you bought. Stratara takes a different route, and it's worth walking through, because it &lt;em&gt;is&lt;/em&gt; the scaling story:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands don't block the caller.&lt;/strong&gt; The default write path is fire-and-forget: a command goes onto a message bus and returns &lt;code&gt;202 Accepted&lt;/code&gt; immediately, while a worker handles it out-of-process. The request thread never waits on business logic, and a traffic spike buffers in the bus instead of pinning your web tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers compete for the work.&lt;/strong&gt; The bus — RabbitMQ or Azure Service Bus — hands each message to whichever worker is free. Add replicas (more pods, more nodes) and they share the load automatically. No leader to elect, no partitions to reassign by hand. Scaling out is a number in a deployment manifest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg48mkinyj620kbzhon96.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg48mkinyj620kbzhon96.png" alt=" " width="800" height="133"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering survives parallelism — through buckets, not locks.&lt;/strong&gt; This is the clever part. Every aggregate id is hashed onto one of &lt;strong&gt;4096 buckets&lt;/strong&gt;, deterministically: the same id always lands in the same bucket. Writes within a bucket serialise through a single-writer lock, so one aggregate's events stay strictly ordered — but &lt;em&gt;different&lt;/em&gt; buckets run fully in parallel. Per-aggregate consistency and cross-aggregate concurrency at the same time, with zero global coordination.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fku5ix2f25azijaoakf39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fku5ix2f25azijaoakf39.png" alt=" " width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;4096 is a power of two on purpose (cheap bit-masking instead of a modulo), and every persisted row — events, snapshots, command log, outbox — carries its bucket id and is indexed on it. So the bucket axis isn't only a lock; it's a partition key you can shard the database along too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read models keep up by subscribing, not polling.&lt;/strong&gt; Projections never ask a table "anything new?" on a timer. They subscribe to the bus, and the write path publishes the event bundle the instant it commits. A read model trails its write by a beat, not by a poll interval — and you're not burning idle "is there work yet?" queries when traffic is quiet.&lt;/p&gt;

&lt;p&gt;Put together, scaling stops being an architecture project and becomes an operations one. Command, projection, and saga workers all scale as competing consumers; the one deliberate exception is the tamper-evidence hash worker, which stays single-instance by design — it's appending to a single chain, and you don't want two writers fighting over its head.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the bus drops, nothing waits in line
&lt;/h2&gt;

&lt;p&gt;One more thing the hundred-a-second premise demands: the fast path can't be the &lt;em&gt;only&lt;/em&gt; path, or a broker hiccup loses commands.&lt;/p&gt;

&lt;p&gt;So the dispatcher tries the bus first — the direct, fast publish. &lt;strong&gt;Only&lt;/strong&gt; if the bus is unreachable does the command land in a durable outbox table, where an &lt;code&gt;OutboxWorker&lt;/code&gt; re-publishes it once the bus is back. In the normal case there's no outbox round-trip on the hot path at all; the durable net only engages on failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt83b2ibh6ar7yospel3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt83b2ibh6ar7yospel3.png" alt=" " width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me be precise, because this is where marketing usually overclaims: the guarantee is &lt;strong&gt;at-least-once&lt;/strong&gt;, not exactly-once. A command can arrive twice — a retry after a crash mid-publish — so handlers are written to be idempotent, and correlation ids make duplicates detectable. "No message silently lost," yes. "Each message exactly once, by magic," no — and anyone selling you the latter without idempotent handlers is selling you something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the floor
&lt;/h2&gt;

&lt;p&gt;None of these techniques is mine to claim — reflection-free dispatch, bucketed single-writer locks, push projections, an outbox fallback all predate me, and you could build any of them into your own append-only store. What eats the months is wiring &lt;em&gt;all&lt;/em&gt; of them together cleanly, lockstep-versioned, and keeping them honest under load. That's the part I didn't want to pay for twice. If you're in .NET and you want your next system — with or without an agent on top — standing on something production-grade from day one, this is my floor.&lt;/p&gt;

&lt;p&gt;Runnable, dependency-free samples are in the repo, docs at docs.stratara.tech. Source-available under MIT License.&lt;/p&gt;

&lt;p&gt;Reading is one thing, building another. Grab the repo, run a sample, put your own first action on top of it — and if it earns a place in your toolbox, a star helps the next person find it. Bring your ideas for where the foundation could get better, here in the comments or as an issue on GitHub. A foundation doesn't get good because &lt;em&gt;one&lt;/em&gt; person builds it; it gets good because many people use it and push exactly where it still gives. 😉&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>programming</category>
      <category>eventsourcing</category>
    </item>
    <item>
      <title>Append-only doesn't mean what you'd hope</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Sat, 30 May 2026 15:51:07 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/append-only-doesnt-mean-what-youd-hope-41go</link>
      <guid>https://dev.to/norbertrosenwinkel/append-only-doesnt-mean-what-youd-hope-41go</guid>
      <description>&lt;p&gt;Event sourcing gets sold on immutability. You don't update, you don't delete, you only append, so the history is permanent.&lt;/p&gt;

&lt;p&gt;It mostly isn't. The events are immutable because your code agrees not to touch them, not because anything actually stops it. Underneath they're still rows in Postgres, and rows have a DBA with write access. A migration that "cleans up" old data. A 2 a.m. query run against the wrong connection. A backup restored with slightly different bytes in it.&lt;/p&gt;

&lt;p&gt;Change one of those rows and a replay won't blink. The aggregate rebuilds, the projections rebuild, everything looks fine. Usually the first person to notice is a customer whose balance is off, and by then the trail is cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chain each event into the next
&lt;/h2&gt;

&lt;p&gt;The trick is small. Give every row two extra columns: a hash of its contents, and the hash of the row before it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frx8mi8pgtp1bcd3lctdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frx8mi8pgtp1bcd3lctdo.png" alt=" " width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hash is &lt;code&gt;SHA-256(previousHash || json(payload))&lt;/code&gt;. Nothing exotic.&lt;/p&gt;

&lt;p&gt;The point is that each hash depends on the one before it. Edit a payload and its hash stops matching. Rewrite that hash to cover for the edit, and now the next row's pointer is wrong. You can't fix one without breaking the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  About forty lines of it
&lt;/h2&gt;

&lt;p&gt;Appending an event hashes it together with the previous one:&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;HashChainedEntry&lt;/span&gt; &lt;span class="nf"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="n"&gt;payload&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;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;GenesisHash&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;[^&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Hash&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;hash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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;entry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HashChainedEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_entries&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;entry&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;entry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="n"&gt;payload&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;payloadJson&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&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;combined&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BlockCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BlockCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&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;SHA256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HashData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined&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;Verifying is the same thing backwards. Walk the rows, recompute, and check two things on each one: the pointer and the hash.&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;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// genesis&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;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;ByteArraysEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PreviousHash&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;EventStreamCorruptedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"previous-hash pointer does not match the prior entry's hash"&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;recomputed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;ByteArraysEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recomputed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hash&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;EventStreamCorruptedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"stored hash does not match a fresh re-hash of the payload (payload was modified after commit)"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hash&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;Bump Alice's $50 deposit to $5,000 straight in the table, and the check stops you cold at the exact row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event stream tampering detected at sequence #2: stored hash does not
match a fresh re-hash of the payload (payload was modified after commit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What that gets you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Someone tries to…&lt;/th&gt;
&lt;th&gt;…and it shows up because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Edit one event's payload&lt;/td&gt;
&lt;td&gt;the re-hash no longer matches the stored hash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewrite the stored hash to match&lt;/td&gt;
&lt;td&gt;the next row's pointer no longer matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete a row from the middle&lt;/td&gt;
&lt;td&gt;the next row's pointer doesn't match its new neighbour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slip in a forged row&lt;/td&gt;
&lt;td&gt;same thing, the pointer chain breaks at the seam&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The honest ceiling
&lt;/h2&gt;

&lt;p&gt;Here's the part people gloss over. That table assumes the attacker is lazy: edit a row, move on, leave the stale hash behind. Someone with full write access doesn't have to be lazy. They can edit the row and then recompute every hash after it. Now the chain is consistent again and the verifier has nothing to say.&lt;/p&gt;

&lt;p&gt;A hash chain is a checksum, not a signature. If you own both ends of it, so does anyone who owns your database. That's the honest ceiling of doing this inside your own four walls, and it's worth saying out loud before someone says it for you in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting out of your own walls
&lt;/h2&gt;

&lt;p&gt;This is what anchoring is for, and it's the part I find actually interesting.&lt;/p&gt;

&lt;p&gt;Next to the per-stream chains, Stratara keeps a second table of anchors. Every so many events it writes down the head of the chain at that point. Each anchor row has a &lt;code&gt;BlockchainTxHash&lt;/code&gt; column, and that column is the hook: you take the anchor and commit it somewhere you don't control. A public blockchain. An RFC 3161 timestamp authority. An &lt;a href="https://opentimestamps.org/" rel="noopener noreferrer"&gt;OpenTimestamps&lt;/a&gt; calendar. A notary. Anything you trust that isn't you.&lt;/p&gt;

&lt;p&gt;Once an anchor lives somewhere out of your reach, the recompute attack falls apart. Your insider can rewrite every hash in the database and still can't touch the value you already pinned elsewhere. The question stops being "is this chain internally consistent" and becomes "does it still match what we committed outside." That second one is much harder to fake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78ipr0t94yws09abnwki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78ipr0t94yws09abnwki.png" alt=" " width="800" height="93"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me be straight about what ships versus what you wire yourself. The anchor table, the worker that writes anchors, and the &lt;code&gt;BlockchainTxHash&lt;/code&gt; column are in the box. Actually pushing an anchor to your source of truth, and checking against it later, is the part you wire up. Stratara doesn't pick the chain for you, the same way it doesn't pick your message broker. The sample at the end runs the whole thing in memory so you can see the shape of it.&lt;/p&gt;

&lt;p&gt;One caveat, said plainly: if someone owns your database &lt;em&gt;and&lt;/em&gt; your anchoring pipeline, they can re-chain and re-anchor and it'll all look fine. The defense only holds if the thing you anchor to is genuinely out of their hands. That's the entire reason to put it outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the hashing happens, and where verifying does
&lt;/h2&gt;

&lt;p&gt;The hashing runs on a background worker, not inline on every append, so writes stay cheap. And cheap is measured, not hoped: hashing a typical event takes well under a microsecond — around 700 ns on a &lt;em&gt;fanless&lt;/em&gt; MacBook Air M4, riding the chip's hardware-accelerated SHA-256 — so the chain worker stays comfortably ahead of a brisk write rate. The chain gets filled in a beat behind the commit. Verifying is a separate thing you do on purpose: a scheduled job, or checking the external anchor. You don't want it on the read path, because that's a &lt;code&gt;SELECT … ORDER BY Sequence&lt;/code&gt; on every query and it ties each read to the integrity check.&lt;/p&gt;

&lt;p&gt;Worth being straight about: nothing in the framework wakes up and hunts for tampering on its own today. The hashes and the anchors are there so that &lt;em&gt;when&lt;/em&gt; you verify — on a schedule, during an audit, after an incident — the evidence is intact and a break lands on the exact row. For a SOC 2 or ISO 27001 audit, the worker's structured logs are the running record that the hashing happened across the period; the verification job is what proves the chain held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, a CQRS and event-sourcing stack for .NET 10. The chaining is the &lt;code&gt;EventStreamHashing&lt;/code&gt; worker, running against Postgres. None of the idea is Stratara-specific though. If you've got an append-only table, you can bolt this on yourself.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/yesbert/Stratara/tree/main/samples/Stratara.Sample.TamperProof" rel="noopener noreferrer"&gt;&lt;code&gt;TamperProof&lt;/code&gt; sample&lt;/a&gt; is the whole story in zero-dependency, in-memory code, in three acts: a clean chain that verifies, a sloppy tamper caught at the exact row, and a full re-chain that sails past the local check but gets caught by an external anchor.&lt;/p&gt;

&lt;p&gt;Wiring it into a real app is more than one &lt;code&gt;dotnet add&lt;/code&gt; — you need the event store, the hashing worker, and a little DI — so the &lt;a href="https://docs.stratara.tech/getting-started/" rel="noopener noreferrer"&gt;getting-started guide&lt;/a&gt; walks the minimal setup. Full docs are at &lt;a href="https://docs.stratara.tech" rel="noopener noreferrer"&gt;https://docs.stratara.tech&lt;/a&gt;, and it's source-available MIT License.&lt;/p&gt;

&lt;p&gt;This is just one slice of Stratara, and honestly the easiest to show off. There's plenty more I want to write up — the tenant-aware encryption side especially, where a tenant's data is cryptographically bound to their own key — without cramming it all into one wall of text. So if this was your kind of thing, stick around: more coming.&lt;/p&gt;

&lt;p&gt;If you're already event sourcing: how would you actually prove to an auditor that nobody's touched the log? Genuinely curious what people are doing here.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>eventsourcing</category>
      <category>security</category>
    </item>
  </channel>
</rss>
