<?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>Forem: Alex Chen</title>
    <description>The latest articles on Forem by Alex Chen (@alex_chen_45b61c234682eb6).</description>
    <link>https://forem.com/alex_chen_45b61c234682eb6</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3883787%2Ff7c6b285-a545-467d-9f79-594a9e5b4e49.png</url>
      <title>Forem: Alex Chen</title>
      <link>https://forem.com/alex_chen_45b61c234682eb6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/alex_chen_45b61c234682eb6"/>
    <language>en</language>
    <item>
      <title>The 50,000-Token Demonstration Nobody Saved: Capturing Agent Trajectories to Train Your Own Code-SLM</title>
      <dc:creator>Alex Chen</dc:creator>
      <pubDate>Thu, 07 May 2026 12:06:14 +0000</pubDate>
      <link>https://forem.com/alex_chen_45b61c234682eb6/the-50000-token-demonstration-nobody-saved-capturing-agent-trajectories-to-train-your-own-4mo8</link>
      <guid>https://forem.com/alex_chen_45b61c234682eb6/the-50000-token-demonstration-nobody-saved-capturing-agent-trajectories-to-train-your-own-4mo8</guid>
      <description>&lt;p&gt;Last Tuesday, Sonnet 4.5 spent forty-three minutes implementing JWT authentication in a project I run. It read four files, wrote a 180-line patch, ran the test suite, watched two tests fail, traced one of the failures to a stale fixture, fixed both, ran the suite again, watched it pass, then squash-merged the work to main with a commit message that read like a senior engineer wrote it. The whole exchange consumed about 50,000 tokens of model output, broken into nineteen &lt;code&gt;AssistantMessage&lt;/code&gt; turns interleaved with twenty-three &lt;code&gt;ToolUseBlock&lt;/code&gt; calls and twenty-one &lt;code&gt;ToolResultBlock&lt;/code&gt; returns.&lt;/p&gt;

&lt;p&gt;I have the final code. I have the commit. I do not have the trajectory.&lt;/p&gt;

&lt;p&gt;I had nineteen turns of expert reasoning — the kind of demonstration that, if you handed it to a smaller model as supervised fine-tuning data, would teach that smaller model how to &lt;em&gt;act like a coding agent&lt;/em&gt;, not just how to write Python. And I threw it on the floor the moment the &lt;code&gt;ResultMessage&lt;/code&gt; arrived, because my harness was wrapped around &lt;code&gt;claude_agent_sdk.query()&lt;/code&gt; like this:&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="n"&gt;result_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&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="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__class__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ResultMessage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result_text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that loop. Eighteen messages walked past it for free. The last one paid the rent.&lt;/p&gt;

&lt;p&gt;This is the post about why I decided that was insane, what I built to fix it, and what it now lets me do — including, eventually, train my own Qwen2.5-Coder fine-tune on Sonnet's distilled coding behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The thing nobody is doing yet, but should be
&lt;/h2&gt;

&lt;p&gt;If you are running an agent harness at any scale — even hobby scale, even one-developer scale — you are paying a Frontier-model API bill &lt;em&gt;and&lt;/em&gt; generating a continuous stream of high-quality expert demonstrations &lt;em&gt;and&lt;/em&gt; throwing them away. The math on this is depressing once you actually run it. A two-week sprint with one agent running ten hours a day at modest concurrency produces something like 500 task trajectories. Each one is, on average, six thousand to twenty thousand tokens of expert thinking, tool use, and code edits, paired with the canonical "right answer" diff that landed on main.&lt;/p&gt;

&lt;p&gt;This is the shape of training data people pay for. Coding-specific SFT corpora don't fall out of the sky. The teams shipping the leading code models scrape GitHub, run synthetic generation pipelines, hire annotators. You have a smaller, narrower, &lt;em&gt;higher-quality&lt;/em&gt; version of that already happening in your dev environment for free, modulo the fact that you are not capturing it.&lt;/p&gt;

&lt;p&gt;The reason most teams aren't doing this isn't technical difficulty. It's a missing primitive. The agent SDK gives you a stream of messages. Most harnesses iterate the stream once and discard it. Adding a tee — a "yield to the caller AND write to a database" wrapper — is eighty lines of code. The hard part is not the tee. The hard part is figuring out &lt;em&gt;what to capture&lt;/em&gt; and &lt;em&gt;what shape to capture it in&lt;/em&gt; so that six months from now, when someone says "let's actually try training that model now," you don't discover you stored the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The two design questions that actually matter
&lt;/h2&gt;

&lt;p&gt;Before any code, two decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What format do you store?&lt;/strong&gt; The naive answer is "store it in the format your fine-tuning library wants." That answer is wrong. Fine-tuning libraries change. The chat template you use today (let's say OpenAI tool-use) is not the chat template you'll use in eighteen months. ShareGPT had its moment, ChatML is having its moment, the next thing is already in someone's repo. If you store in the trained-model format, you locked yourself in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's your training label?&lt;/strong&gt; A trajectory by itself is imitation-learning data — "here's what the expert did, copy it." That gets you to mid-tier capability, full stop. The reason DPO and rejection-sampling matter is they let you do &lt;em&gt;preference&lt;/em&gt; learning: "of these K candidate solutions, which one matches the actual answer?" To do that, you need a &lt;em&gt;label&lt;/em&gt; — a canonical "this is what the correct final state looked like" against which candidate completions get scored. If you only stored the trajectory, you've half-stored the dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answers I landed on, after going down both wrong paths first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capture the superset.&lt;/strong&gt; Store the raw SDK message stream — every &lt;code&gt;AssistantMessage&lt;/code&gt; with its &lt;code&gt;ThinkingBlock&lt;/code&gt; and &lt;code&gt;TextBlock&lt;/code&gt; and &lt;code&gt;ToolUseBlock&lt;/code&gt; content, every &lt;code&gt;UserMessage&lt;/code&gt; with its &lt;code&gt;ToolResultBlock&lt;/code&gt; content, every model name, every usage tally. Don't project to a chat format at capture time. Projection is cheap and reversible from the superset; the reverse direction isn't true. This is the same principle as event-sourcing in databases: store the events, project the views.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capture the diff.&lt;/strong&gt; When the agent's branch squash-merges to main, the resulting commit hash &lt;em&gt;is&lt;/em&gt; the ground-truth label. &lt;code&gt;git show &amp;lt;sha&amp;gt;&lt;/code&gt; gives you the canonical patch the expert eventually landed. Add one nullable column to your task table, PATCH the SHA back after squash, and at export time you can attach the diff to every successful trajectory. Now your dataset isn't "trajectory." It's "trajectory plus the right answer." DPO and rejection sampling become trivial future work because the label is already on disk.&lt;/p&gt;

&lt;p&gt;That's the design. The implementation is small enough to fit on a napkin.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The recorder is a tee, and it's eighty lines
&lt;/h2&gt;

&lt;p&gt;The whole capture surface is a single async iterator wrapper:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&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="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RecordingDestination&lt;/span&gt;&lt;span class="p"&gt;,&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;own_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&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="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&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="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_serialize_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&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;event_type&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;agent_message&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;task_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;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace recording failed for task %s turn %d; continuing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_info&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;own_client&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;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Yield to the caller; tee to the events table. The four design choices baked into those eighty lines are worth naming because they're the ones that go wrong if you skip past them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Caller-side, not runner-side.&lt;/strong&gt; The wrapper sits at the call site that already knows &lt;code&gt;session_id&lt;/code&gt; and &lt;code&gt;task_id&lt;/code&gt;. The agent runner stays a pure SDK wrapper. This is the boring choice and the right choice — it keeps the runner module reusable in contexts (testing, ad-hoc scripts) where there's no state service to record into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best-effort.&lt;/strong&gt; A network blip, a state-service restart, a transient permission error — none of them abort the agent. The recorder catches every exception, logs a warning, and continues. The asymmetry is correct: the agent's job is to ship the feature, not to ship the trace. Lost traces are a nuisance. Lost agent runs are a fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lossless serialization.&lt;/strong&gt; &lt;code&gt;_serialize_message&lt;/code&gt; walks the SDK Message object's attributes generically — model, stop_reason, usage, content blocks — and JSON-serializes them with no projection, no opinion. Whatever shape the SDK emits is what lands in the database. When the SDK adds a new content-block type next quarter, the recorder doesn't break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One event per Message, not per content-block.&lt;/strong&gt; Tool-use ↔ tool-result correlation stays implicit via the SDK's IDs; reconstructing the conversation at export time is straightforward; the events table doesn't 5x its row count for marginal queryability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The storage is the existing events table. No new schema. The &lt;code&gt;payload&lt;/code&gt; is a JSON column. SQLite handles 1–3 MB per task comfortably. A hundred tasks is 100–300 MB. Disk is cheap. WAL mode makes the writes essentially free at this volume. The state service this lands inside has been doing this for other event types since v0.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The merge_commit_sha column does most of the conceptual work
&lt;/h2&gt;

&lt;p&gt;The single largest design decision in this whole feature is one nullable &lt;code&gt;String(40)&lt;/code&gt; column on the task table. Everything else is mechanism. This column is &lt;em&gt;meaning&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;When the harness squash-merges a feature branch to main, &lt;code&gt;squash_merge()&lt;/code&gt; returns &lt;code&gt;{"merged": True, "commit_hash": "abc1234"}&lt;/code&gt;. The cli.py task handler PATCHes that hash back to the corresponding task row. The PATCH is best-effort and try/excepted because the task is already complete by then — a failed PATCH costs you the diff label for that record, not the agent run.&lt;/p&gt;

&lt;p&gt;At export time, &lt;code&gt;--include-diff&lt;/code&gt; reads the column and shells out to &lt;code&gt;git show --pretty=format: &amp;lt;sha&amp;gt;&lt;/code&gt; against the project's git repo. The diff lands on the JSONL record as &lt;code&gt;final_diff&lt;/code&gt;. Now every &lt;code&gt;outcome="success"&lt;/code&gt; trajectory carries the canonical patch the expert eventually shipped — the one that survived the test suite, the code review, the squash merge.&lt;/p&gt;

&lt;p&gt;This is the difference between "imitation data" and "imitation + reward". It's also the difference between "a corpus you can SFT on" and "a corpus you can DPO on later." You don't need the DPO pipeline today — the schema's already forward-compatible, so when you decide it's time, the labels are sitting there on disk waiting.&lt;/p&gt;

&lt;p&gt;I did not appreciate how much this column matters until I started thinking about evaluation. If you're going to fine-tune a smaller model on captured trajectories, you need a metric that says "did the smaller model learn to land the right diff?" Not "did the smaller model produce text that looks like the expert" — that's BLEU on assistant content, and BLEU on assistant content is a vanity metric. The honest metric is &lt;strong&gt;diff similarity&lt;/strong&gt;: reconstruct the smaller model's proposed patch from its tool-call sequence (Edit / Write blocks), score it line-level Jaccard plus &lt;code&gt;difflib.SequenceMatcher.ratio()&lt;/code&gt; against &lt;code&gt;final_diff&lt;/code&gt;, and call that your eval. You cannot run that eval without the ground-truth column. The column is the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Format projection is a one-page module
&lt;/h2&gt;

&lt;p&gt;With the superset captured, projection to any chat format at export time is mechanical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI tool-use&lt;/strong&gt; — fold thinking + text + tool_use blocks into one assistant message with &lt;code&gt;tool_calls&lt;/code&gt;; emit each tool_result block as its own &lt;code&gt;role: tool&lt;/code&gt; message. Default format. Reads natively into HuggingFace &lt;code&gt;apply_chat_template(tools=...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ShareGPT&lt;/strong&gt; — flatten tool calls to &lt;code&gt;&amp;lt;tool_call name="X"&amp;gt;{...}&amp;lt;/tool_call&amp;gt;&lt;/code&gt; text. Lossy but trl/Axolotl ShareGPT loaders eat it without complaining.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatML&lt;/strong&gt; — generic &lt;code&gt;&amp;lt;|im_start|&amp;gt;&lt;/code&gt; tags; no tool semantics; useful for non-tool-using base models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;raw-jsonl&lt;/strong&gt; — direct dump of the SDK message stream. Use when you want to write your own templating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The projector module is two hundred lines. The interesting half is &lt;code&gt;_assistant_from_blocks&lt;/code&gt;, which folds an assistant message's heterogeneous content blocks into one OpenAI-format message. Thinking blocks become a &lt;code&gt;thinking&lt;/code&gt; field (a non-standard extension that most loaders silently drop, which is fine — if you want chain-of-thought training, use &lt;code&gt;--format raw-jsonl&lt;/code&gt;). Text blocks concatenate to &lt;code&gt;content&lt;/code&gt;. Tool-use blocks become &lt;code&gt;tool_calls[]&lt;/code&gt; with their JSON arguments stringified. The shape mirrors what &lt;code&gt;apply_chat_template&lt;/code&gt; expects when you pass &lt;code&gt;tools=...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hygiene at the JSONL layer is two more functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe&lt;/strong&gt; — drop trajectories where &lt;code&gt;(prompt, final_diff)&lt;/code&gt; already appears in the corpus. Default mode is "both must match." Cheap and obvious — protects against the user re-running the same task five times during debugging and polluting their training set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic split&lt;/strong&gt; — train/val/test by SHA-256 of &lt;code&gt;task_id&lt;/code&gt;. Same input set always partitions the same way, so val and test holdouts stay stable across re-exports. Important when you're iterating on the export pipeline and want to know whether a metric change came from new data or new partition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the export mechanism. Reader → filter → projector → redactor → splitter → JSONL. Each stage is replaceable. The reader is the only one with database access. Everything downstream operates on dicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Redaction has to happen at export, not capture
&lt;/h2&gt;

&lt;p&gt;This was the choice I almost got wrong, and I want to flag it because the wrong instinct is &lt;em&gt;very&lt;/em&gt; tempting.&lt;/p&gt;

&lt;p&gt;The wrong instinct: "I should redact secrets at capture time, before they hit the database." This feels safer. It's not. It's destructive. If your redaction rule has a bug — and your redaction rule will have a bug, because regex secret-detection is not a solved problem — you've lost the original data forever. You can't re-export with a fixed rule. You can't audit what was actually said. The DB is downstream of the redactor and you've thrown away your ground truth.&lt;/p&gt;

&lt;p&gt;The right instinct: redact at &lt;em&gt;export&lt;/em&gt; time. Keep the database authoritative. Treat the project-local &lt;code&gt;.claw-forge/state.db&lt;/code&gt; as having the same trust boundary as the source code itself — if a laptop compromise leaks the DB, the source code is the bigger problem. The export pipeline applies redaction rules to projected JSONL records &lt;em&gt;after&lt;/em&gt; projection, so re-exporting with new rules is a one-line operation. You can also generate a fully-faithful, un-redacted export for local fine-tuning experiments, then a redacted export for sharing. The DB is the same.&lt;/p&gt;

&lt;p&gt;The redaction module is three composable rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SecretsRule&lt;/code&gt; — well-known patterns: AWS keys, GitHub PATs, Stripe keys, Anthropic keys, OpenAI keys, GCP API keys, Authorization headers. Conservative by design — better to miss some than to mangle innocuous text that happens to look secret-shaped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UsernamesRule&lt;/code&gt; — substitutes &lt;code&gt;/Users/&amp;lt;you&amp;gt;/&lt;/code&gt; and &lt;code&gt;/home/&amp;lt;you&amp;gt;/&lt;/code&gt; with &lt;code&gt;&amp;lt;REDACTED:username&amp;gt;&lt;/code&gt; while preserving directory structure. File layout is meaningful learning signal; the username is not.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CustomPatternsRule&lt;/code&gt; — user-supplied regex list from the YAML config. For project-specific stuff: customer IDs, internal hostnames, ticket prefixes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;Redactor&lt;/code&gt; walks records recursively. Strings get every rule applied. Dicts and lists recurse. Everything else passes through. Replacement markers are structured (&lt;code&gt;&amp;lt;REDACTED:secret&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;REDACTED:username&amp;gt;&lt;/code&gt;) so the model never learns to fabricate the redacted form — it learns "this is a placeholder, ignore."&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The opt-in is two flags and a banner
&lt;/h2&gt;

&lt;p&gt;Anthropic's Usage Policies prohibit using Claude outputs to develop models that compete with their services. This feature is squarely in the grey zone unless you treat it carefully. I built the gate with that in mind.&lt;/p&gt;

&lt;p&gt;There are two flags in &lt;code&gt;claw-forge.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;training_traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;acknowledged_terms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both must be true before the recorder emits anything. If &lt;code&gt;enabled: true&lt;/code&gt; but &lt;code&gt;acknowledged_terms: false&lt;/code&gt;, the state service logs a one-time banner at startup with the relevant policy excerpt and &lt;em&gt;does not record traces&lt;/em&gt;. The user has to flip the second flag explicitly.&lt;/p&gt;

&lt;p&gt;In v0.7.1 I made &lt;code&gt;claw-forge init&lt;/code&gt; scaffold both flags as &lt;code&gt;true&lt;/code&gt; by default — the scaffold itself acknowledges the policy via the comments in the YAML, and the user is opting in by running &lt;code&gt;init&lt;/code&gt;. Existing &lt;code&gt;claw-forge.yaml&lt;/code&gt; files are untouched (the scaffold only writes when the file is absent). This was a deliberate friction-vs-discovery tradeoff: gate-by-default would mean nobody ever discovers the feature; default-on means everyone discovers it but the policy reminder is one &lt;code&gt;cat claw-forge.yaml | head -100&lt;/code&gt; away. I chose discovery.&lt;/p&gt;

&lt;p&gt;This is a feature that's intended for &lt;strong&gt;personal/internal distillation&lt;/strong&gt; — building a smaller model that imitates &lt;em&gt;your own&lt;/em&gt; Claude usage on &lt;em&gt;your own&lt;/em&gt; code, for &lt;em&gt;your own&lt;/em&gt; internal use. Distribution of derived models is the user's responsibility and emphatically out of scope. The provenance fields on every JSONL record (model name, capture date, claw-forge version, applied redaction rules) preserve a verifiable lineage if you ever need to demonstrate "this corpus came from my own Claude usage."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The training recipe is short and lives outside the harness
&lt;/h2&gt;

&lt;p&gt;claw-forge stops at JSONL export. The downstream Unsloth/Axolotl/trl pipeline lives in a separate user repo. The harness has no &lt;code&gt;train&lt;/code&gt; command, no model registry, no inference layer. The reasons are scope hygiene: training stacks change fast, GPU dependencies are heavyweight, and the harness is supposed to run on any laptop. The recipe is documented in a markdown file (&lt;code&gt;docs/training/unsloth-recipe.md&lt;/code&gt;) and ships as a reference, not as code.&lt;/p&gt;

&lt;p&gt;The recipe at the time of writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base model&lt;/strong&gt;: &lt;code&gt;unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit&lt;/code&gt;. Coder-specialised, native tool-use chat template, Apache 2.0 licence, fits a 24 GB consumer GPU with LoRA. DeepSeek-Coder-V2-Lite-Instruct as the alternative if you have raw eval scores to chase and don't mind MoE finickiness for LoRA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoRA config&lt;/strong&gt;: r=16, alpha=32, target modules q/k/v/o + gate/up/down, dropout 0, gradient checkpointing on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training&lt;/strong&gt;: per-device batch 2, grad accumulation 8 (effective 16), 3 epochs, lr 2e-4, cosine schedule, adamw_8bit, max_seq_length 8192, bf16 if available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eval on the held-out test split&lt;/strong&gt;: ROUGE-L on assistant content (cheap reasoning-quality proxy), and &lt;strong&gt;diff similarity&lt;/strong&gt; on the model's reconstructed patch vs the captured &lt;code&gt;final_diff&lt;/code&gt; (the real correctness signal). The latter is the one that matters; the former is the one you watch during training to spot collapse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a corpus of ~500 trajectories at average 6K tokens each, expect single-digit hours on a 4090 for a full training run. Calibrate with a 50-task dry run before committing. That's not a thousand-GPU pretraining job; it's a weekend's worth of consumer-grade compute on top of months of accumulated agent traces. The economics make sense at single-developer scale, which is the part nobody seems to be talking about yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What this doesn't solve (be honest)
&lt;/h2&gt;

&lt;p&gt;Some things this design explicitly does not handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distribution rights.&lt;/strong&gt; I keep coming back to this because it's the part most likely to bite someone. Training a model on Sonnet's outputs and using it internally on your own code is one thing. Distributing that model — uploading weights to HuggingFace, releasing a derivative product — is a different thing and not protected by anything in this pipeline. Read the policy. Talk to a lawyer. The provenance stamping helps with audit; it does not authorize redistribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eval beyond diff similarity.&lt;/strong&gt; Diff similarity catches "did the model land the right code change" but it doesn't catch "did the model produce a clean, well-reasoned, well-commented solution." For that you need either human eval or LLM-as-judge eval, both of which sit outside the harness. The corpus &lt;em&gt;enables&lt;/em&gt; both, but the harness doesn't ship them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-Claude-version mixing.&lt;/strong&gt; Every trace stamps the originating model name. Mixing trajectories captured under Sonnet 4.5 with trajectories captured under Opus 4.7 gives you a heterogeneous teacher signal. Sometimes that's what you want — pooled expert demonstrations across model strengths — and sometimes it isn't (when the lower-capability traces are noise). The provenance field lets you filter, but the harness has no opinion about &lt;em&gt;whether&lt;/em&gt; you should.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capturing failure modes that didn't go through claw-forge.&lt;/strong&gt; If the engineer drops out of the harness and edits a file by hand, none of that lands in the trace. The corpus represents what the &lt;em&gt;agent&lt;/em&gt; did, not what the &lt;em&gt;human&lt;/em&gt; did to clean up after the agent. For pure agent-distillation that's fine; for "train a model that handles the real workflow including human-in-the-loop fixups," this is a gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-project corpus building.&lt;/strong&gt; Each project has its own state.db. Combining corpora across projects is &lt;code&gt;cat *.jsonl&lt;/code&gt; plus a check that the &lt;code&gt;provenance.claude_model&lt;/code&gt; and &lt;code&gt;claw_forge_version&lt;/code&gt; fields are compatible. Works fine for SFT, but if you're seriously building a multi-project corpus you want a manifest and a deduplicator that operates across files. That's tooling I haven't built yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest framing: this design captures a &lt;em&gt;very&lt;/em&gt; specific kind of training data — the agentic coding loop on your own codebase, paired with the ground-truth diff that landed. That kind of data is unusually hard to come by and unusually valuable. It's not a substitute for general-purpose pre-training data, and it's not going to give you a model that handles tasks outside your codebase's distribution. It is going to give you a model that, on tasks similar to the ones you've been running, behaves more like Sonnet than the base Qwen2.5-Coder weights do. That's the win.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. The cultural shift I keep coming back to
&lt;/h2&gt;

&lt;p&gt;There's a meta-point that took me too long to internalize.&lt;/p&gt;

&lt;p&gt;If you're paying for Frontier-model API calls, the &lt;em&gt;expensive&lt;/em&gt; artifact isn't the code that ships. The code that ships is checkable, reviewable, reversible. The expensive artifact is the &lt;em&gt;expert demonstration&lt;/em&gt; — the nineteen turns of senior-engineer reasoning that took the model forty-three minutes to produce. You're paying for the trajectory whether you save it or not. Saving it is the line between "I rented a senior engineer for an hour" and "I rented a senior engineer for an hour and learned how they work."&lt;/p&gt;

&lt;p&gt;The harness equivalent of this insight is: &lt;strong&gt;the events table is a training corpus in disguise.&lt;/strong&gt; The schema was already there. The state service was already writing to it. Adding a new event type and tee-ing the SDK message stream into it is eighty lines of code. The data was always going to be high-value; the only question was whether you'd capture it.&lt;/p&gt;

&lt;p&gt;I think more harnesses are going to do this in the next twelve months, and I think it's going to start showing up as a competitive feature. The teams running large agent fleets without trace capture are paying for expert demonstrations and discarding them. The teams running with trace capture have a data flywheel: every agent run produces both a feature and a training example. After six months of that, you have something to fine-tune. After twelve months, you might have a smaller model that handles the easy 60% of your tasks for an order of magnitude less per-call cost than the Frontier model that produced the training data. The Frontier model still handles the hard 40%. The cost curve bends.&lt;/p&gt;

&lt;p&gt;That's not a hypothetical; that's just SFT plus rejection-sampling with a corpus you already paid for. The mechanism is well-understood. The piece nobody is shipping yet — at least, not in the open-source agent harness landscape I follow — is the &lt;em&gt;capture primitive&lt;/em&gt;. I built it because I wanted it. I'm sharing the design because I think the rest of the ecosystem will arrive at this primitive eventually, and the sooner it's a commodity, the sooner the interesting work above it can start.&lt;/p&gt;




&lt;p&gt;If your harness throws away every &lt;code&gt;Message&lt;/code&gt; except the final &lt;code&gt;ResultMessage&lt;/code&gt;, you are walking past free training data every day. The fix is eighty lines, one nullable column, and a config gate. Build it before you next run the swarm.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Alex Chen builds AI-coding-agent infrastructure shipped to production. He runs ten-agent swarms daily and is currently waiting for his Qwen2.5-Coder fine-tune to finish so he can find out whether the months of captured Sonnet trajectories were worth the disk.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>claude</category>
      <category>llm</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Architectural Shape Hint: A Spec-Time Trick That Lets 10 AI Agents Run in Parallel Without Stepping on Each Other</title>
      <dc:creator>Alex Chen</dc:creator>
      <pubDate>Sun, 03 May 2026 06:12:10 +0000</pubDate>
      <link>https://forem.com/alex_chen_45b61c234682eb6/the-architectural-shape-hint-a-spec-time-trick-that-lets-10-ai-agents-run-in-parallel-without-2g69</link>
      <guid>https://forem.com/alex_chen_45b61c234682eb6/the-architectural-shape-hint-a-spec-time-trick-that-lets-10-ai-agents-run-in-parallel-without-2g69</guid>
      <description>&lt;p&gt;I run agent swarms now. Not "an agent" — &lt;em&gt;agents&lt;/em&gt;, plural, in flight at once, each working on a different feature against the same repo. Ten agents per session is normal. Twenty isn't unusual when the spec is well-decomposed. The token math works, the wall-clock math works, the model latency hides inside the swarm because something is always landing while something else is still compiling. The economics make a strong case for parallel execution as the default.&lt;/p&gt;

&lt;p&gt;Until you hit the wall everyone hits: &lt;strong&gt;two agents touched the same file&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I've spent the better part of the year fighting this. I've shipped four layers of runtime defense. They all work and none of them are the answer. The answer turned out to be one attribute on the spec. This is the post about that one attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The four layers nobody told you you'd need
&lt;/h2&gt;

&lt;p&gt;Before I describe the fix, let me describe the disease — because if you're running parallel agents and you &lt;em&gt;don't&lt;/em&gt; recognize this stack, you're probably going to recognize it next week.&lt;/p&gt;

&lt;p&gt;When two agents in flight at once both want to edit &lt;code&gt;src/router/routes.py&lt;/code&gt;, here's what claw-forge (the harness I work in) does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;File-claim locks.&lt;/strong&gt; Each task declares &lt;code&gt;touches_files=[...]&lt;/code&gt; upfront. The dispatcher refuses to start a second task that wants a file currently held by a running task. The second task defers to the next dispatch cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-dispatch worktree sync.&lt;/strong&gt; Before the agent runs, the harness merges &lt;code&gt;target_branch&lt;/code&gt; into the feature branch &lt;em&gt;inside the worktree&lt;/em&gt;. If &lt;code&gt;target&lt;/code&gt; moved while the task was queued, the merge happens before any token is spent. Conflicts surface as &lt;code&gt;resume_conflict:&lt;/code&gt; failures with the offending file list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch-up rebase inside &lt;code&gt;squash_merge&lt;/code&gt;.&lt;/strong&gt; When the agent's branch finally squash-merges to main and conflicts with concurrent work, the harness merges target into the branch and retries the squash automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume-on-retry preamble.&lt;/strong&gt; If a task fails mid-run, the next attempt picks up the worktree as-is, with a prompt prefix listing what's already committed and what failed last time. The agent doesn't redo the first 60% of the work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This stack is correct. Each layer earns its keep. If I deleted any one of them, real users would file real bug reports within 48 hours. But notice what they all have in common: &lt;strong&gt;they are reactive&lt;/strong&gt;. Every layer is a response to "two agents touched the same file." The conflict has already happened by the time the layer fires.&lt;/p&gt;

&lt;p&gt;What if it never happened?&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Conflicts are usually predictable from architecture
&lt;/h2&gt;

&lt;p&gt;Sit down with a senior engineer who has worked on a codebase for six months. Hand them a list of feature requests. Ask: "If we built these in parallel with one engineer per feature, where would the merge conflicts happen?" They'll be right within five minutes. They don't run the merges. They look at the codebase's structure and &lt;em&gt;know&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The reason they know is that conflicts cluster around &lt;strong&gt;architectural surfaces&lt;/strong&gt;. A few specific files — the dispatcher, the routes table, the global event bus, the error envelope, the auth middleware — get touched by every feature. Most other files are owned by one feature each. The conflict surface isn't uniformly distributed across the repo. It's concentrated on the structural choke points.&lt;/p&gt;

&lt;p&gt;This is the same insight that drives plugin architectures in big software systems. WordPress plugins don't conflict because each lives in &lt;code&gt;wp-content/plugins/&amp;lt;name&amp;gt;/&lt;/code&gt;. VS Code extensions don't conflict because each lives in its own directory and registers through a stable API. The host is small and stable. The plugins are everything else.&lt;/p&gt;

&lt;p&gt;If you build your codebase as a small core plus many plugins, &lt;em&gt;and&lt;/em&gt; your spec tells the harness which features are plugins versus core, &lt;em&gt;and&lt;/em&gt; the harness honors that distinction at scheduling time — then ten agents working on ten plugins literally cannot conflict. They are editing files in ten different directories. The locks are decorative. The catch-up rebase is dead code. The pre-dispatch sync is a no-op.&lt;/p&gt;

&lt;p&gt;This was the unlock. Encode the architectural intent in the spec. Let the scheduler use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two shapes, one attribute
&lt;/h2&gt;

&lt;p&gt;Every feature in our specs now carries an architectural-shape attribute. There are exactly two shapes that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shape="plugin"&lt;/code&gt;&lt;/strong&gt; — vertical features. Live in their own directory, own their own data model, own their own tests. Adding or removing the plugin doesn't touch sibling plugins. Examples: "user can register," "user can edit profile," "task CRUD with tag filtering." Each lives in &lt;code&gt;src/plugins/&amp;lt;name&amp;gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shape="core"&lt;/code&gt;&lt;/strong&gt; — cross-cutting concerns. Edit files used by every plugin. Examples: "all endpoints validate JWT," "uniform RFC 7807 error envelope," "global rate limit," "database connection pool." Each lives in &lt;code&gt;src/core/&amp;lt;concern&amp;gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No tier, no taxonomy, no UML. Two values. The simplicity is load-bearing — if the classifier had three values it would have ten by next quarter, and the scheduling rule would have to handle a Cartesian product of cases.&lt;/p&gt;

&lt;p&gt;A spec entry now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;feature&lt;/span&gt; &lt;span class="na"&gt;index=&lt;/span&gt;&lt;span class="s"&gt;"14"&lt;/span&gt; &lt;span class="na"&gt;shape=&lt;/span&gt;&lt;span class="s"&gt;"plugin"&lt;/span&gt; &lt;span class="na"&gt;plugin=&lt;/span&gt;&lt;span class="s"&gt;"auth"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;description&amp;gt;&lt;/span&gt;User can register with email and password&lt;span class="nt"&gt;&amp;lt;/description&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/feature&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;feature&lt;/span&gt; &lt;span class="na"&gt;index=&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt; &lt;span class="na"&gt;shape=&lt;/span&gt;&lt;span class="s"&gt;"core"&lt;/span&gt;
         &lt;span class="na"&gt;touches_files=&lt;/span&gt;&lt;span class="s"&gt;"src/core/middleware/auth.py"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;description&amp;gt;&lt;/span&gt;All endpoints validate JWT on incoming requests&lt;span class="nt"&gt;&amp;lt;/description&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/feature&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;plugin="auth"&lt;/code&gt; attribute auto-fills &lt;code&gt;touches_files&lt;/code&gt; to &lt;code&gt;["src/plugins/auth/**"]&lt;/code&gt;. The harness now knows that feature 14 will only touch files inside &lt;code&gt;src/plugins/auth/&lt;/code&gt;. Two &lt;code&gt;shape="plugin"&lt;/code&gt; features with different &lt;code&gt;plugin&lt;/code&gt; names are &lt;em&gt;guaranteed&lt;/em&gt; to be file-disjoint. Not "probably." Not "usually." Guaranteed by directory boundaries.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;shape="core"&lt;/code&gt; features the auto-derivation can't help — cross-cutting work touches a specific file by name. The author writes &lt;code&gt;touches_files="src/core/middleware/auth.py"&lt;/code&gt; explicitly. The parser refuses any spec where &lt;code&gt;shape="core"&lt;/code&gt; lacks a &lt;code&gt;touches_files&lt;/code&gt; value. Cross-cutting work without a declared file set is a bug in the spec, not a runtime decision the dispatcher gets to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The scheduling rule that follows
&lt;/h2&gt;

&lt;p&gt;Once shape is in the spec, the dispatcher gets two new rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shape="plugin"&lt;/code&gt; tasks dispatch freely up to &lt;code&gt;--concurrency N&lt;/code&gt;.&lt;/strong&gt; Their file sets are disjoint by construction. The file-claim lock layer becomes a sanity check rather than a primary defense. Plugin tasks scale linearly with concurrency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shape="core"&lt;/code&gt; tasks single-flight.&lt;/strong&gt; At most one cross-cutting task runs at a time, regardless of &lt;code&gt;--concurrency&lt;/code&gt;. Two core tasks both want to edit the auth middleware? They serialize. Always. No clever overlap analysis, no "well actually they touch different lines." Cross-cutting work is cheap to serialize — it's a small minority of features — and the cost of getting it wrong is high.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks without &lt;code&gt;shape&lt;/code&gt;&lt;/strong&gt; (legacy specs) fall through to the existing concurrency cap + file-claim lock behavior. Backward compatibility is free because the new rules are gated on &lt;code&gt;task.shape IS NOT NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scheduler's filter is twelve lines of Python:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_ready_tasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TaskNode&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="c1"&gt;# Cross-cutting (shape="core") tasks single-flight: drop any
&lt;/span&gt;    &lt;span class="c1"&gt;# candidate ``core`` task from the ready set if another core task
&lt;/span&gt;    &lt;span class="c1"&gt;# is already running.
&lt;/span&gt;    &lt;span class="n"&gt;any_core_running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;running&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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="n"&gt;any_core_running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ready&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;core&lt;/span&gt;&lt;span class="sh"&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;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire enforcement mechanism. The scheduler has no opinion about parallelism beyond this. The &lt;code&gt;touches_files&lt;/code&gt; lock layer handles the second-line defense for cases where a plugin author lied about their shape (which the code review should catch separately).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Why this works structurally, not just behaviorally
&lt;/h2&gt;

&lt;p&gt;The thing that makes this approach durable is that the safety property is &lt;strong&gt;structural&lt;/strong&gt;: it's a consequence of file-system layout, not of clever runtime detection.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;feat/plugins/auth/&lt;/code&gt; and &lt;code&gt;feat/plugins/profile/&lt;/code&gt; are the only file sets two agents touch, there is no possible interleaving where they conflict. Not because the harness is smart. Because the files don't overlap. The same way two &lt;code&gt;git worktree&lt;/code&gt; instances on different branches can edit different files without any locking — git just doesn't see them as a conflict.&lt;/p&gt;

&lt;p&gt;Compare this to the old approach: "predict conflicts at runtime by checking which files each agent claims to touch." That works &lt;em&gt;if&lt;/em&gt; every agent honestly declares its file set. In practice, agents trying to wire a plugin into a registry often need to edit the registry too. They forget to declare the registry file. The lock layer doesn't fire. The merge conflicts at squash time. The whole reactive stack kicks in.&lt;/p&gt;

&lt;p&gt;The plugin-shape approach refuses to be in that situation. If your codebase has a registry that every plugin has to edit, that registry is a hotspot and you should restructure it — or declare it as &lt;code&gt;shape="core"&lt;/code&gt; and serialize work on it. The architecture catches up to the parallelism, not the other way around.&lt;/p&gt;

&lt;p&gt;This is also why the harness composes naturally with my project's &lt;code&gt;boundaries&lt;/code&gt; audit pass. That tooling already identifies hotspot files (registries, route tables, dispatch chains) and refactors them into plugin-extensible patterns. After a &lt;code&gt;boundaries apply --auto&lt;/code&gt; pass, the codebase is more amenable to plugin-shape features — fewer surfaces remain that &lt;em&gt;force&lt;/em&gt; a &lt;code&gt;shape="core"&lt;/code&gt; declaration. The two pieces — spec-time architectural intent and codebase structural refactoring — pull in the same direction. Each makes the other more effective.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The brownfield path: refactor first, then extend
&lt;/h2&gt;

&lt;p&gt;Greenfield projects can be built plugin-shaped from day one. Brownfield projects — i.e. every project worth working on — usually have an existing dispatcher / route table / event bus that gets touched by every feature. You can't bolt plugin-shape semantics onto a codebase whose architecture isn't ready for them.&lt;/p&gt;

&lt;p&gt;So the brownfield workflow has an extra step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;analyze&lt;/code&gt; — generate a manifest with stack, conventions, test baseline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;boundaries audit&lt;/code&gt; — emit &lt;code&gt;boundaries_report.md&lt;/code&gt; listing extension hotspots and the refactor pattern best suited to each (registry / split / route-table / extract-collaborators).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;boundaries apply --auto&lt;/code&gt; — refactor each hotspot one at a time on its own feature branch with test gating. Squash-merges to main on green; reverts on red.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/create-spec&lt;/code&gt; — the slash command reads &lt;code&gt;boundaries_report.md&lt;/code&gt; first. If hotspots remain unrefactored, it warns the user before generating any spec. Then it asks &lt;code&gt;shape&lt;/code&gt; per feature.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claw-forge add&lt;/code&gt; — runs the planner against the now-shape-aware spec.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skipping step 3 is the costly mistake. New features land as &lt;code&gt;shape="plugin"&lt;/code&gt;, but the file-claim lock catches them when they try to edit the un-refactored hotspot, the dispatcher fails the task with &lt;code&gt;resume_conflict&lt;/code&gt;, and the agent has wasted one full attempt on stale state. Refactoring up front is cheaper than discovering you need to mid-flight. The boundaries harness exists exactly to make that "up front" step automatic.&lt;/p&gt;

&lt;p&gt;The cultural ask is: when adding non-trivial features to an existing codebase, do the structural work &lt;em&gt;first&lt;/em&gt;. That's not a new principle — it's "make the change easy, then make the easy change," Kent Beck, twenty years ago. Plugin-shape specs make this principle observable: if you can't write a clean spec without declaring half your features as &lt;code&gt;shape="core"&lt;/code&gt;, that's a structural signal, not a spec-writing failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What this doesn't solve (be honest)
&lt;/h2&gt;

&lt;p&gt;I want to be careful not to oversell this. Here's what plugin-shape specs explicitly do not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic conflicts inside a single plugin.&lt;/strong&gt; Two tasks for the same plugin (&lt;code&gt;plugin="auth"&lt;/code&gt;) still serialize via &lt;code&gt;touches_files&lt;/code&gt; locks. Adding "user can reset password" while "user can change email" is in flight will defer the second one until the first finishes. This is fine — it's the correct behavior — but it limits intra-plugin parallelism to one task at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-plugin coupling that wasn't designed in.&lt;/strong&gt; If your &lt;code&gt;tasks&lt;/code&gt; plugin imports from your &lt;code&gt;auth&lt;/code&gt; plugin's internals (and your codebase doesn't enforce plugin isolation via lint or import boundaries), edits to &lt;code&gt;auth/&lt;/code&gt; can break &lt;code&gt;tasks/&lt;/code&gt; after merge. The spec doesn't catch this; tests do. Treat the spec as a parallelism hint, not an isolation guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared infrastructure changes.&lt;/strong&gt; A migration that adds a column to the &lt;code&gt;users&lt;/code&gt; table is &lt;code&gt;shape="core"&lt;/code&gt; because the migrations directory is shared. Two such migrations serialize. They have to — concurrent migration writers race on the migration sequence number. Don't try to plugin-ify your migrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specifications written as shape-agnostic.&lt;/strong&gt; A feature whose acceptance criteria say "the system shall …" without naming a directory or file is hard to classify. Either rewrite the criterion to reference a concrete piece of the system, or accept that the feature won't get a &lt;code&gt;shape&lt;/code&gt; attribute and will fall through to legacy scheduling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest framing: plugin-shape specs make the &lt;em&gt;common&lt;/em&gt; parallelism case (many vertical features against a clean plugin host) trivial-safe. The hard cases — cross-cutting concerns, coupled plugins, shared infrastructure — still require engineering judgment. The win is that the common case becomes the default rather than the exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The cultural shift this enables
&lt;/h2&gt;

&lt;p&gt;There's a meta-point here that's bigger than the technical mechanism.&lt;/p&gt;

&lt;p&gt;Most discussions of "AI agents at scale" focus on the &lt;em&gt;agent's&lt;/em&gt; capabilities — context window, reasoning depth, tool-use accuracy. Those matter, but they're not where the leverage is. The leverage is in &lt;strong&gt;encoding the human's architectural intent in a place the harness can read&lt;/strong&gt;. Specs are not just task descriptions for the agent. They're scheduling hints for the orchestrator. They're isolation declarations for the locks. They're refactoring targets for the boundaries pass. They're documentation for the next human reviewer.&lt;/p&gt;

&lt;p&gt;When you start writing specs that carry this much load, the spec format itself stops being a casual prose blob and becomes a structured contract. XML attributes that look fussy at first — &lt;code&gt;index&lt;/code&gt;, &lt;code&gt;depends_on&lt;/code&gt;, &lt;code&gt;shape&lt;/code&gt;, &lt;code&gt;plugin&lt;/code&gt;, &lt;code&gt;touches_files&lt;/code&gt; — earn their keep because every one of them maps to a runtime decision the harness will otherwise have to guess. Guessing is what produces the four-layer reactive stack. Declaring is what makes that stack a quiet backstop instead of a daily firefight.&lt;/p&gt;

&lt;p&gt;This is the same shift that happened in deployment automation a decade ago: declarative manifests beat imperative shell scripts because the &lt;em&gt;intent&lt;/em&gt; — "I want three replicas behind a load balancer" — was machine-readable rather than buried in a sequence of side-effecting commands. Plugin-shape specs are doing the same thing for AI-agent orchestration: making intent readable so the orchestrator can stop guessing.&lt;/p&gt;

&lt;p&gt;If you're building AI-coding-agent infrastructure right now and your dispatcher is making scheduling decisions based purely on what's in the queue, you're building the imperative-shell-script version of this. The declarative version — where the agents read what the human meant rather than what they typed — is meaningfully better, and it doesn't require a smarter model. It requires a more structured spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The minimum implementation
&lt;/h2&gt;

&lt;p&gt;If you want to try this in your own harness, the minimum viable version is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One attribute on your task/feature object.&lt;/strong&gt; Call it &lt;code&gt;shape&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, whatever — but pick &lt;em&gt;exactly two&lt;/em&gt; values. "vertical" and "horizontal" works. "feature" and "infra" works. Two values. The temptation to add a third is a trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One auto-derivation rule.&lt;/strong&gt; When &lt;code&gt;shape="plugin"&lt;/code&gt; and a &lt;code&gt;plugin="X"&lt;/code&gt; is set, the file-claim list defaults to &lt;code&gt;["plugins/X/**"]&lt;/code&gt;. One line of helper code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One scheduling rule.&lt;/strong&gt; When any &lt;code&gt;shape="core"&lt;/code&gt; task is running, drop other core tasks from the ready set. Twelve lines of Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One spec-time validation.&lt;/strong&gt; &lt;code&gt;shape="core"&lt;/code&gt; without an explicit file list raises an error before the planner runs. Five lines.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole ship. Total surface area: maybe 50 lines of harness code, plus the spec schema extension and the docs to teach the spec author what to declare.&lt;/p&gt;

&lt;p&gt;The minimum tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A round-trip test that parses the documented XML example and asserts the auto-derived file lists match (guards against doc/code drift).&lt;/li&gt;
&lt;li&gt;A scheduler test that adds two &lt;code&gt;shape="core"&lt;/code&gt; tasks and confirms only one is in the ready set when the other is running.&lt;/li&gt;
&lt;li&gt;A scheduler test that confirms &lt;code&gt;shape="plugin"&lt;/code&gt; tasks dispatch freely when a core task is running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three tests. Done. The pattern compounds: now your codebase has a place to put new shape-aware behavior, and your spec authors have a place to encode new architectural intent. Future work — auto-derived shape inference via static analysis, telemetry on adoption rates, conflict-prediction at scheduler time — all builds on this primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Closing thought
&lt;/h2&gt;

&lt;p&gt;The thing that took me too long to internalize is that &lt;strong&gt;parallelism is a property of the architecture, not the runtime&lt;/strong&gt;. You can't bolt safe parallelism onto a codebase whose architecture forces every feature through the same chokepoint. You can build elaborate runtime defenses against the resulting conflicts — and you should, because real codebases always have &lt;em&gt;some&lt;/em&gt; chokepoints — but the runtime defenses are the patch, not the cure.&lt;/p&gt;

&lt;p&gt;The cure is to design codebases where parallelism is structurally safe, and to encode that structural intent in the spec so the orchestrator can lean on it. Two values, one attribute, twelve lines of scheduler logic. That's the surface area of the win. The cost was a year of fighting the four-layer reactive stack to recognize that the layers were treating symptoms, not the disease.&lt;/p&gt;

&lt;p&gt;If your AI-agent harness is dropping conflicts on you, look at your spec format before you look at your dispatcher. The dispatcher is downstream. The spec is where the architecture lives.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Alex Chen builds AI-coding-agent infrastructure shipped to production. He runs ten-agent swarms daily and would like to thank the team's &lt;code&gt;boundaries&lt;/code&gt; harness for finally making it stop hurting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Building an Autonomous Crypto Trading Bot</title>
      <dc:creator>Alex Chen</dc:creator>
      <pubDate>Sun, 03 May 2026 06:05:58 +0000</pubDate>
      <link>https://forem.com/alex_chen_45b61c234682eb6/building-an-autonomous-crypto-trading-bot-2lc4</link>
      <guid>https://forem.com/alex_chen_45b61c234682eb6/building-an-autonomous-crypto-trading-bot-2lc4</guid>
      <description>&lt;p&gt;I've been spending too much time inside trading bot codebases lately. Most of them are one of two things: a 200-line Jupyter notebook that someone calls a "system," or a sprawling monorepo where the strategy logic and exchange integration are so tangled that you can't swap exchanges without rewriting half the code.&lt;/p&gt;

&lt;p&gt;A few weeks ago I went deep on &lt;strong&gt;AlphaStrike&lt;/strong&gt;, a production-grade crypto perpetual futures bot. Not because the returns were headline-grabbing (though a 2.4 Sharpe is nothing to sneeze at), but because the architecture solves problems most of us hand-wave past. I want to walk through what's interesting, what's novel, and what I'd steal for my own projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Space
&lt;/h2&gt;

&lt;p&gt;Algorithmic crypto trading sounds simple at the whiteboard: read prices, predict direction, place orders, manage risk. In practice, every layer of that stack will try to kill you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exchanges are inconsistent.&lt;/strong&gt; WEEX, Binance, Hyperliquid — every one has different symbol formats, different REST paradigms, different WebSocket lifecycles, different ways of representing a position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models decay.&lt;/strong&gt; A signal that worked last quarter doesn't work this quarter. Pretending otherwise is how accounts get blown up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volatility is non-stationary.&lt;/strong&gt; Static leverage and fixed position sizes are a lie you tell yourself until you wake up at -40% drawdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure quant is fragile.&lt;/strong&gt; Numbers don't know that the SEC just sued the second-largest exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AlphaStrike's design isn't trying to be the smartest bot. It's trying to be the bot that's still alive in 12 months. That's a different optimization target, and it shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture, Top-Down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXCHANGE → DATA GATEWAY → FEATURE LAYER → FEATURE VALIDATOR
                                                    │
                                                    ▼
EXECUTION ← RISK LAYER ← STRATEGY LAYER ← ML LAYER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight stages, every one of them able to halt the pipeline on its own. That's the first lesson: &lt;strong&gt;every layer is a potential circuit breaker.&lt;/strong&gt; If features fail validation (PSI drift, KS test, CUSUM), no signal reaches the model. If the risk layer flags exposure, no order reaches the exchange. Fail-closed by default.&lt;/p&gt;

&lt;p&gt;Let me walk through the four pieces I actually want to talk about.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Exchange Abstraction Done Right
&lt;/h2&gt;

&lt;p&gt;This is where most trading bots rot. AlphaStrike defines two &lt;code&gt;Protocol&lt;/code&gt; classes — &lt;code&gt;ExchangeRESTProtocol&lt;/code&gt; and &lt;code&gt;ExchangeWebSocketProtocol&lt;/code&gt; — and every adapter (WEEX, Hyperliquid, Binance, generic OpenAPI) implements them. The trading logic only talks to the unified protocol.&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;@runtime_checkable&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExchangeRESTProtocol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&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;get_ticker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;UnifiedTicker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;place_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UnifiedOrder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;UnifiedOrderResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;get_positions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UnifiedPosition&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="bp"&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;set_leverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leverage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unified data models (&lt;code&gt;UnifiedOrder&lt;/code&gt;, &lt;code&gt;UnifiedPosition&lt;/code&gt;, &lt;code&gt;UnifiedCandle&lt;/code&gt;) are the contract. Every adapter has a &lt;code&gt;mappers.py&lt;/code&gt; that translates between exchange-native shapes and the unified shapes. Symbol normalization happens at the adapter boundary — internally everything is &lt;code&gt;BTCUSDT&lt;/code&gt;, externally it becomes &lt;code&gt;cmt_btcusdt&lt;/code&gt; or whatever WEEX wants this week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I care:&lt;/strong&gt; I've shipped trading code where exchange-specific assumptions leaked into the strategy. It's death by a thousand &lt;code&gt;if exchange == "binance"&lt;/code&gt; cuts. The Protocol-based approach keeps the boundary honest. You add a new exchange by writing one adapter file, not by hunting through the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The ML Layer That Doesn't Trust Itself
&lt;/h2&gt;

&lt;p&gt;The signal pipeline runs &lt;strong&gt;12 categories&lt;/strong&gt; of weak signals — order flow, microstructure, volatility, correlation, sentiment, seasonality, statistical, price action, volume, derivatives, alternative, macro — and combines them through a regime-aware ensemble. This is the explicitly Renaissance/Medallion-inspired bit, and the backtest deltas are real:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Single Signal&lt;/th&gt;
&lt;th&gt;12-Category Ensemble&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sharpe&lt;/td&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Win Rate&lt;/td&gt;
&lt;td&gt;52%&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max Drawdown&lt;/td&gt;
&lt;td&gt;-15%&lt;/td&gt;
&lt;td&gt;-8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But the part I find genuinely novel is the &lt;strong&gt;signal decay tracker&lt;/strong&gt;. Every signal logs its predictions, the system records outcomes, and signals get auto-retired when their rolling accuracy drops below 48%. Weight is &lt;code&gt;(edge × 2)²&lt;/code&gt;, so signals with real edge get amplified and weak signals fade out without anyone touching code.&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="n"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accuracy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;            &lt;span class="c1"&gt;# 0.52 accuracy → 0.02 edge
&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;         &lt;span class="c1"&gt;# quadratic weighting of strong signals
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;accuracy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.48&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the right way to do it. Most "ensemble" systems use static weights tuned once and forgotten. Here the weights are alive — they update with reality. Models that lose their edge get fired by the system itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Dynamic Leverage as a First-Class Citizen
&lt;/h2&gt;

&lt;p&gt;Static leverage is the crypto equivalent of running with scissors while drunk. AlphaStrike treats leverage as a continuous control variable:&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="n"&gt;leverage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;vol_factor&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;dd_factor&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;perf_factor&lt;/span&gt;

&lt;span class="n"&gt;vol_factor&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normal_vol&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;current_vol&lt;/span&gt;     &lt;span class="c1"&gt;# clamped 0.3 to 1.5
&lt;/span&gt;&lt;span class="n"&gt;dd_factor&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;        &lt;span class="c1"&gt;# tiered by drawdown
&lt;/span&gt;&lt;span class="n"&gt;perf_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;half_kelly_fraction&lt;/span&gt;          &lt;span class="c1"&gt;# 0.6 to 1.2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real scenarios from the doc:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;th&gt;Leverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High vol (5%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In 12% drawdown&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong perf + low vol&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All bad (high vol + DD + losing)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The leverage state lives in &lt;code&gt;data/state/leverage_state.json&lt;/code&gt; so it survives restarts. When the system reduces from 5x to 2x because volatility spiked, the next process boot doesn't forget. That detail matters more than it sounds — most bots reset to defaults on restart and quietly take on more risk than the operator thinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The LLM Layer That Knows Its Place
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me. AlphaStrike has an LLM decision layer — a local Ollama-served &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; — but its design philosophy is the opposite of what's currently fashionable. The LLM does not generate signals. It does not pick trades. It does not "reason about the market."&lt;/p&gt;

&lt;p&gt;It only intervenes &lt;strong&gt;when performance degrades.&lt;/strong&gt; When the rolling win rate drops below 40%, drawdown crosses 15%, or you stack 5 consecutive losses, the system hands the LLM a structured performance report and a tightly scoped tool palette:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;adjust_conviction(symbol, threshold, reason)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;adjust_position_size(symbol, multiplier, reason)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;adjust_leverage(new_leverage, reason)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;disable_shorts(symbol, reason)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;disable_asset(symbol, duration_hours, reason)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no_action(reason)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example LLM response when SOL is having a 25% win rate, 22% drawdown, 7-loss streak:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adjust_position_size"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SOL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"multiplier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.3&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="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adjust_conviction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SOL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"new_threshold"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;85&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="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disable_shorts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SOL"&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="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"send_alert"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&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="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"critical"&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;That's the right shape for LLMs in financial systems: &lt;strong&gt;bounded actions, explicit triggers, no inference loops touching live capital.&lt;/strong&gt; The model doesn't have to be smart, it has to be defensive. A 1.5B parameter local model is more than enough when the action space is six tools wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Took Away
&lt;/h2&gt;

&lt;p&gt;Three things I'm stealing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Protocol-based exchange abstraction.&lt;/strong&gt; No more &lt;code&gt;if exchange ==&lt;/code&gt; chains. Define the contract once, swap implementations behind it. This generalizes way past trading.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Self-retiring signals with quadratic edge weighting.&lt;/strong&gt; Static feature weights are tech debt the moment you ship them. Make signal decay a first-class concept and let the data prune your own model.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LLM-as-circuit-breaker, not LLM-as-strategist.&lt;/strong&gt; The hype-cycle take is "use the LLM to pick trades." The mature take is "use the LLM to recognize when your quant system is dying and apply targeted, reversible, well-typed interventions." The hype-cycle take blows up your account. The mature take saves it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I'd build next: an offline evaluation harness for the LLM's tool-call decisions. Right now the LLM's interventions only get evaluated by their downstream P&amp;amp;L impact, which is noisy and slow. A counterfactual replay framework — "what would have happened if the LLM had done nothing, or chosen a different tool?" — would let you tune the trigger thresholds and the prompt without burning real capital. That's where I'd put the next two weeks of engineering time.&lt;/p&gt;

&lt;p&gt;Trading bots are not magic. They're software systems that have to survive volatility, exchange flakiness, model decay, and operator panic. The systems that survive are the ones that take all four threats seriously at the architecture level — not the ones with the prettiest backtest curve.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
      <category>cryptocurrency</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
