<?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: Nic Lydon</title>
    <description>The latest articles on DEV Community by Nic Lydon (@niclydon).</description>
    <link>https://dev.to/niclydon</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%2F3908081%2F03e413b1-9c57-4561-9b6e-3ee8b60bc188.png</url>
      <title>DEV Community: Nic Lydon</title>
      <link>https://dev.to/niclydon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/niclydon"/>
    <language>en</language>
    <item>
      <title>The Confabulation Cascade: When Your Agent Learns Nothing From Its Own Mistakes</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 23 Jun 2026 01:09:49 +0000</pubDate>
      <link>https://dev.to/niclydon/the-confabulation-cascade-when-your-agent-learns-nothing-from-its-own-mistakes-m08</link>
      <guid>https://dev.to/niclydon/the-confabulation-cascade-when-your-agent-learns-nothing-from-its-own-mistakes-m08</guid>
      <description>&lt;p&gt;My infrastructure analyst agent was stuck in a loop I didn’t have a name for yet.&lt;/p&gt;

&lt;p&gt;It would write a SQL query with a hallucinated column name. The query would fail with a Postgres error. My error handler would fire back the real column list from &lt;code&gt;pg_attribute&lt;/code&gt;. The agent would read it, acknowledge the correction in its reasoning trace, and then write the exact same wrong column name on the next attempt.&lt;/p&gt;

&lt;p&gt;Not a different wrong column. The same one.&lt;/p&gt;

&lt;p&gt;I started calling it the confabulation cascade. Here’s what was actually happening, why it’s a tool design problem more than a model problem, and what I did about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Nexus is my personal intelligence platform. It runs 8+ autonomous agents against a 191-table Postgres schema, doing things like weekly life chapter analysis, relationship health tracking, and biographical inference from 24 years of personal data. The infrastructure analyst agent is responsible for querying those tables to surface patterns and anomalies.&lt;/p&gt;

&lt;p&gt;When agents write SQL in Nexus, they go through &lt;code&gt;handleQueryDb&lt;/code&gt; in &lt;code&gt;tool-executor.ts&lt;/code&gt;. The handler enforces SELECT-only access, applies agent-scoped roles, and on failure calls &lt;code&gt;buildQueryDbSchemaHint()&lt;/code&gt; from &lt;code&gt;query-db-schema-hint.ts&lt;/code&gt; to augment the error message.&lt;/p&gt;

&lt;p&gt;That last part is where the problem lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reactive Schema Hint
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;buildQueryDbSchemaHint()&lt;/code&gt; does two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;On “column does not exist” error: introspects &lt;code&gt;pg_attribute&lt;/code&gt; and returns the real column list for that table&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On “table does not exist” error: searches &lt;code&gt;pg_class&lt;/code&gt; for similar table names and suggests them&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is useful. When it triggers, the agent gets accurate schema information. The problem is the word “when.” The hint is purely reactive. It only fires after a query fails.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;describe_table&lt;/code&gt; tool. No &lt;code&gt;get_schema&lt;/code&gt; call. No way for an agent to ask “what columns does &lt;code&gt;aurora_life_chapters&lt;/code&gt; have?” before writing SQL. The only path to ground truth is trial and error.&lt;/p&gt;

&lt;p&gt;So the agent’s loop was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Generate a query. Column name comes from training weights plus context – call it a confident prior.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Query fails. Error message arrives with real column list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Agent processes correction as context in its next generation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Training prior reasserts. Same wrong column appears in the new query.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Go to 1.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent wasn’t ignoring the correction. It was receiving two competing signals: an error-message correction grounded in reality, and a stronger schema prior embedded in the model’s weights. The correction arrived once. The prior arrived every token. Guess which one won.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Tool Design Problem
&lt;/h2&gt;

&lt;p&gt;It’s tempting to frame this as “the model should pay more attention to error messages.” That framing puts the fix in prompt engineering territory – add emphasis, reorder the context, tell the model to really read the hint this time.&lt;/p&gt;

&lt;p&gt;That might help at the margin. It doesn’t fix the structural issue.&lt;/p&gt;

&lt;p&gt;The structural issue is that I designed a tool surface that makes confident guessing the only entry point to accurate information. The agent had no way to verify structure before acting. It could only learn by failing. When a model’s training prior is strong, that learning channel is lossy.&lt;/p&gt;

&lt;p&gt;Compare this to how you’d design a tool for a human. If you give a human an API and they ask what fields it accepts, you give them documentation. You don’t make them submit malformed requests until the error messages teach them the schema. The human version of the confabulation cascade is a poorly documented API with no reference – you keep guessing based on what similar APIs look like, and sometimes the error messages stick, and sometimes they don’t.&lt;/p&gt;

&lt;p&gt;Same failure mode. Different substrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: describe_table
&lt;/h2&gt;

&lt;p&gt;The fix is a proactive schema introspection tool. Agents call it before writing queries, not after failing them.&lt;/p&gt;

&lt;p&gt;The implementation is straightforward:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function handleDescribeTable(
  tableName: string
): Promise&amp;lt;{ columns: Array&amp;lt;{ name: string; type: string; nullable: boolean }&amp;gt; }&amp;gt; {
  // Validate input -- public schema only, no injection surface
  const sanitized = tableName.replace(/[^a-z0-9_]/g, '');

  const result = await db.query(`
    SELECT column_name, data_type, is_nullable
    FROM information_schema.columns
    WHERE table_schema = 'public'
      AND table_name = $1
    ORDER BY ordinal_position
  `, [sanitized]);

  if (result.rows.length === 0) {
    // Suggest similar tables rather than returning empty
    const similar = await findSimilarTables(sanitized);
    throw new Error(
      `Table '${sanitized}' not found in public schema.` +
      (similar.length ? ` Did you mean: ${similar.join(', ')}?` : '')
    );
  }

  return {
    columns: result.rows.map(row =&amp;gt; ({
      name: row.column_name,
      type: row.data_type,
      nullable: row.is_nullable === 'YES',
    }))
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Register it in the agent’s tool grants. Add it to the tool executor dispatch. Done.&lt;/p&gt;

&lt;p&gt;The resulting agent behavior:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Before writing SQL against an unfamiliar table, call &lt;code&gt;describe_table&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Get back authoritative column names and types.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Write the query against verified schema.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cascade stopped. Not because the model got smarter, but because it no longer needed to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Pattern
&lt;/h2&gt;

&lt;p&gt;If your agents are writing tool calls against real data stores – databases, APIs, file systems – ask yourself: can they verify structure before acting, or can they only learn by failing?&lt;/p&gt;

&lt;p&gt;The answer changes what class of bugs you’re going to see.&lt;/p&gt;

&lt;p&gt;Reactive error hints are valuable. They’re not sufficient. An agent that can only discover reality through failure is operating in a state of managed hallucination: wrong until corrected, corrected until the prior reasserts, back to wrong.&lt;/p&gt;

&lt;p&gt;Proactive introspection tools break the loop at the design level. The agent can ask first. That’s not a prompt engineering fix. That’s a tool surface decision.&lt;/p&gt;

&lt;p&gt;It’s the same principle as the difference between defensive error handling and input validation. Catching the exception is better than crashing. Never constructing the invalid input is better than catching it. Move the check earlier.&lt;/p&gt;

&lt;p&gt;For agents writing SQL: &lt;code&gt;describe_table&lt;/code&gt; before the query beats &lt;code&gt;schema_hint&lt;/code&gt; after the failure. The loop that took me a debugging session to understand takes zero sessions to encounter if the tool surface doesn’t require guessing in the first place.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Nexus is my personal intelligence platform, running on private hardware. The agent runtime, job system, and Postgres schema are all home-grown. Posts about the architecture live at&lt;/em&gt; &lt;a href="http://niclydon.io" rel="noopener noreferrer"&gt;&lt;em&gt;niclydon.io&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The Drift from Chat to Backlog: How My AI Task Planning Evolved Over Three Months</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 16 Jun 2026 21:39:49 +0000</pubDate>
      <link>https://dev.to/niclydon/the-drift-from-chat-to-backlog-how-my-ai-task-planning-evolved-over-three-months-2akg</link>
      <guid>https://dev.to/niclydon/the-drift-from-chat-to-backlog-how-my-ai-task-planning-evolved-over-three-months-2akg</guid>
      <description>&lt;p&gt;Three months ago, my entire task-management system was a chat window I'd lose when the tab closed. Today it's a Postgres backlog that three different coding agents — Claude Code, Codex, Grok — pull work off autonomously, stamp with attribution, and close against git history. I never decided to build a project-management system. I just kept hitting a wall, patching it, and hitting the next one the patch exposed.&lt;/p&gt;

&lt;p&gt;There's a clean way to read the whole arc, though, and it comes down to a single variable: &lt;strong&gt;where the plan lives.&lt;/strong&gt; Watch that, and every step makes sense — including why you probably want to stop well before the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I run a self-hosted personal data platform called Nexus, on a 128GB Strix Halo box named Furnace, surrounded by ~100 repos: MCP servers, ingestion pipelines, iOS apps, content tooling. My execution tools are Claude Code and Codex CLI. The work is bursty — during one 35-day stretch in spring I shipped roughly 557K lines across those repos — and that throughput is the pressure that broke each planning approach in turn. At a calmer pace you'll hit the same walls later, but you'll hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: The plan lives in the chat (mid-to-late March)
&lt;/h2&gt;

&lt;p&gt;At the start there was no task system. Planning &lt;em&gt;was&lt;/em&gt; the conversation. I'd open a chat, think out loud about an architecture problem, get to something coherent, and then go build it. The artifact of planning was a better mental model in my head, not a written thing.&lt;/p&gt;

&lt;p&gt;This is, for the record, exactly what every best-practice guide tells you to do, and it's right. The math is brutal and well-known: if Claude makes the right call 80% of the time on any single decision, a feature with 20 decision points lands all 20 at 0.8^20 — about &lt;strong&gt;1%&lt;/strong&gt;. Planning collapses those 20 live decisions into a reviewed spec where each one is already made. I'd never give up the plan-first instinct; it's the one habit from this whole story that never changed.&lt;/p&gt;

&lt;p&gt;The problem was narrower: the plan evaporated when the thread ended. A late-March session designing a multi-agent system for Nexus produced genuinely good architecture — deterministic behavior under load, agents that self-regulate instead of spiraling, adaptive thresholds. None of it was anywhere I could act on the next morning except my memory and a scrollback buffer. At one-feature-a-day that's survivable. At my pace it was lossy in a way that actively cost me work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wall:&lt;/strong&gt; plans that exist only in chat history can't be acted on later, can't be prioritized against each other, and can't be handed to anything but the version of you that remembers the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: The plan lives in a file (mid-to-late April)
&lt;/h2&gt;

&lt;p&gt;The first durable fix was embarrassingly simple: a &lt;code&gt;TODO.md&lt;/code&gt; in each repo. But the &lt;em&gt;structure&lt;/em&gt; I landed on is the part worth stealing, because it wasn't a checklist. Each item was a small spec. Here's a real one, still in my &lt;code&gt;broadside/TODO.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Idempotency on publish operations&lt;/span&gt;

&lt;span class="gs"&gt;**Status:**&lt;/span&gt; captured — flagged during the 2026-04-26 reality-sync session.
&lt;span class="gs"&gt;**Trigger:**&lt;/span&gt; before letting an agent publish unsupervised at any volume.

Today, POST /api/posts/[id]/twitter (and the bluesky / linkedin / devto
siblings) don't refuse a re-publish. If a publish succeeds upstream but the
response gets lost in a network blip, an agent's retry would publish a
duplicate — visibly, to the audience.

&lt;span class="gs"&gt;**What the work will involve:**&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Before calling upstream: SELECT posted_url, status FROM posts WHERE id=$1.
   If already posted, refuse with 409 + { already_posted, posted_url }.
&lt;span class="p"&gt;2.&lt;/span&gt; Optional: accept an Idempotency-Key header, TTL'd table, ~10min window.
&lt;span class="p"&gt;3.&lt;/span&gt; Update the broadside_publish_to_platform MCP tool description accordingly.

&lt;span class="gs"&gt;**Risks worth knowing:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; The "republish" path is sometimes intentional (manual delete + re-fire).
  Mitigate with a posted_at recency check or a force=true param.

&lt;span class="gs"&gt;**Why it matters:**&lt;/span&gt; an agent that double-posts even once is visible to the
audience; the cost is reputational, not just internal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things made this carry weight beyond a checkbox:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status + date.&lt;/strong&gt; Every item is stamped with when it was captured. Trivial-sounding; it's what lets you reason about staleness later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger, not priority.&lt;/strong&gt; Instead of P1/P2/P3, each item records the &lt;em&gt;condition&lt;/em&gt; under which it becomes urgent. "Before letting an agent publish unsupervised" tells future-me exactly when to pull this off the shelf; "high priority" tells me nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The work is pre-decided.&lt;/strong&gt; That numbered list is a plan captured at the moment I understood the problem best. Hand it to Claude Code three weeks later and the 20 decisions are already made — Phase 1's whole value, persisted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Risks are written down.&lt;/strong&gt; The "republish is sometimes intentional" note is exactly the edge case I'd have forgotten and an agent would have trampled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the recurring phrase: &lt;strong&gt;"reality-sync session."&lt;/strong&gt; Concretely, that was a 20-minute pass, usually before a planning block: open each repo's &lt;code&gt;TODO.md&lt;/code&gt; next to its recent &lt;code&gt;git log&lt;/code&gt;, close anything the commits show as already shipped, and re-date anything still open so I could tell stale from live at a glance. Reconciling the plan against ground truth on a cadence — that habit turns out to be the seed of everything in Phase 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wall:&lt;/strong&gt; &lt;code&gt;TODO.md&lt;/code&gt; is per-repo. With ~100 repos, I had no single surface that could tell me what to work on next across all of them, no way to prioritize globally, and nothing an agent could &lt;em&gt;pull from&lt;/em&gt; as a queue. The plan was durable but fragmented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: The plan lives in a database with a gate (early-to-mid May onward)
&lt;/h2&gt;

&lt;p&gt;This is the structural leap. Task state moved out of flat files and into Nexus's Postgres as the &lt;strong&gt;Operator Backlog (OB)&lt;/strong&gt; — with a real intake-to-execution lifecycle instead of a list.&lt;/p&gt;

&lt;p&gt;The shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Work enters as a &lt;strong&gt;candidate&lt;/strong&gt;, in &lt;code&gt;pending&lt;/code&gt; state — not yet a real task. A candidate won't become an OB item until I approve it in a &lt;code&gt;#pmo-review&lt;/code&gt; Discord flow.&lt;/li&gt;
&lt;li&gt;Approval mints an &lt;code&gt;OB-#####&lt;/code&gt; row in &lt;code&gt;operator_backlog_items&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Items move through &lt;strong&gt;status lanes&lt;/strong&gt; — &lt;code&gt;requires_triage&lt;/code&gt; → &lt;code&gt;requires_decision&lt;/code&gt; → &lt;code&gt;requires_investigation&lt;/code&gt; → &lt;code&gt;requires_clickops&lt;/code&gt; → &lt;code&gt;autonomous_safe&lt;/code&gt; — and agents drain those lanes.&lt;/li&gt;
&lt;li&gt;Every commit references its OB id, joining the backlog directly to git history. &lt;code&gt;OB-27081 H2/M2 — close /register on RS&lt;/code&gt; is a real commit subject across several of my repos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lanes are the interesting part, and they map almost exactly onto a distinction Anthropic draws in &lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Building Effective Agents&lt;/a&gt;: the difference between work an agent can drive autonomously and work that needs a human checkpoint "before irreversible actions." &lt;code&gt;autonomous_safe&lt;/code&gt; is the lane an agent can just &lt;em&gt;do&lt;/em&gt;. &lt;code&gt;requires_decision&lt;/code&gt; is the lane that needs me. The backlog isn't just storage; it's a router that sorts work by how much human judgment it still needs.&lt;/p&gt;

&lt;p&gt;The single most important addition in this phase wasn't the lanes, though. It was the &lt;strong&gt;approval gate.&lt;/strong&gt; Phase 2 captured indiscriminately — anything I typed that looked like a task got written down. Phase 3 added a filter, and I know exactly why, because I watched it fail without one. From a real session log on June 3:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Drained &lt;code&gt;requires_triage&lt;/code&gt; + &lt;code&gt;requires_decision&lt;/code&gt; queues (19 items → autonomous_safe/closed/ignored); 8 decisions made; &lt;strong&gt;discovered auto-filer over-captures on soft prose in narration (e.g. "blocked on" → filed OB-4736 to tighten regex)&lt;/strong&gt;; priority-lane starvation (skillopt-train starving PM jobs) diagnosed in OB-4715.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An automated process was scraping my session narration for tasks and mistaking the phrase "blocked on" — used conversationally — for a real blocker. The system was filing garbage into its own backlog. The fix (OB-4736) was itself filed &lt;em&gt;as an OB item&lt;/em&gt;, through the gate. The backlog had become self-correcting: its own intake bugs are tracked in the same substrate as everything else.&lt;/p&gt;

&lt;p&gt;That same log entry shows the daily rhythm this phase settled into. "Draining queues" became a literal, recurring operation — pull the items in a lane, make the decisions, move them to &lt;code&gt;autonomous_safe&lt;/code&gt; or close them. Eight decisions in one sweep. It reads like an on-call shift, because that's effectively what it is: I'm the operator, the backlog is the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The wall:&lt;/strong&gt; a database-backed queue with a governance gate is great, but it assumes disciplined intake and it assumes &lt;em&gt;someone&lt;/em&gt; drains the lanes. As volume grew, I was the bottleneck. The backlog could hold more work than I could personally execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: Many agents drain the backlog (late May → now)
&lt;/h2&gt;

&lt;p&gt;The most recent shift isn't about how tasks are tracked — Phase 3 settled that. It's about &lt;strong&gt;who executes them, and how you stay accountable when it's not just you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The OB backlog is now a shared work queue that multiple runners pull from: Claude Code, Codex, and Grok, each tagged so I can tell after the fact which agent did which item. The same status lanes from Phase 3 keep it safe — an agent only picks up work already in &lt;code&gt;autonomous_safe&lt;/code&gt;, never anything still sitting in &lt;code&gt;requires_decision&lt;/code&gt;. Conflict is handled by leases: a runner claims an item, a second runner sees it's taken and skips it, and if the first agent dies the lease expires and the item frees itself.&lt;/p&gt;

&lt;p&gt;Attribution stamping is the load-bearing piece. Because each commit and OB resolution is tagged by runner, "who did this and why" stays answerable even with three agents touching ~100 repos — and the rule is that the human owns the commit while the runner tag lives in the backlog, never forged into git history. Each run executes in its own isolated git worktree, so parallel agents never touch the same files.&lt;/p&gt;

&lt;p&gt;That's the whole machine. Here's the moment it stopped being theory for me. OB-1623 was "wire a model-provenance footer into report delivery." Claude Code claimed it on May 30, started working — and then &lt;em&gt;refused to finish it.&lt;/em&gt; It had discovered the task's premise was wrong: the function the task named was a shared primitive with ten callers, and the files it pointed at didn't even hold the data the footer needed. Instead of forcing a fix that would have quietly broken nine other call sites, it blocked the item, filed a corrected prerequisite as a new OB, and released its claim with a note explaining exactly why. Two days later, after the prerequisite landed, Codex picked up the same OB cold — no shared memory with the Claude Code run, just the backlog row and the blocking note — and shipped it end to end: PR merged, Nexus deployed to both Furnace and Crucible at &lt;code&gt;c584d2a8&lt;/code&gt;, the live footer renderer verified emitting the right markers.&lt;/p&gt;

&lt;p&gt;Read that sequence again, because it's the whole point of Phase 4 in one item. Two different models, no human mediating the handoff, and the system &lt;em&gt;self-corrected across the gap&lt;/em&gt; — one agent's refusal to do the wrong thing became another agent's clean win, because the reason for the refusal was written down in the one place both of them could see. That's not parallelism. That's the backlog doing the thing a good engineering team does: catching a bad assumption before it ships, and carrying the correction forward to whoever picks the work up next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;Here's the whole arc as a table. Read the "Fixed" and "New limit" columns as a chain — each phase's new limit is the next phase's reason to exist.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Where the plan lives&lt;/th&gt;
&lt;th&gt;Fixed&lt;/th&gt;
&lt;th&gt;New limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Conversational&lt;/td&gt;
&lt;td&gt;Chat history&lt;/td&gt;
&lt;td&gt;Decision quality (plan-first)&lt;/td&gt;
&lt;td&gt;Plans evaporate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. TODO.md&lt;/td&gt;
&lt;td&gt;Per-repo files&lt;/td&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;No global view or priority&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. OB / PMO&lt;/td&gt;
&lt;td&gt;Postgres + approval gate&lt;/td&gt;
&lt;td&gt;Global queue, governance, routing&lt;/td&gt;
&lt;td&gt;Needs disciplined intake; you drain it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Multi-agent OB&lt;/td&gt;
&lt;td&gt;Backlog + worktrees + attribution&lt;/td&gt;
&lt;td&gt;Parallel execution, accountability&lt;/td&gt;
&lt;td&gt;Coordination overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things are worth pulling out of that table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: you do not need to reach Phase 4 to get most of the value.&lt;/strong&gt; Take the Phase 2 &lt;code&gt;TODO.md&lt;/code&gt; format — status, trigger, pre-decided steps, risks — and nothing else. It's a text file; it costs nothing; and everything after it is just scaling that same captured-plan idea to more repos and more executors. If you steal one thing from this post, steal that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, and this is the part I'd defend hardest:&lt;/strong&gt; one habit spans all four phases and predates the tooling. &lt;em&gt;I re-ground against real state before I plan.&lt;/em&gt; It's the April "reality-sync session." It's the Phase 3 gate reconciling candidates against truth. And it shows up, almost word for word, when I catch an analysis cutting corners. A workflow review I ran in June leaned on convenient pre-summarized views instead of the raw tables, and my response was blunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This doesn't seem like it's aware of any of the PMO processes or project initiation… did you look through all of the actual raw ingestion tables?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tooling got more elaborate across these four months; the discipline never changed: &lt;strong&gt;plan only against verified current state.&lt;/strong&gt; The backlog, in the end, is just the most durable place I've found to keep that state — so that planning, whether it's me or an agent doing it, starts from the ground and not from a guess. The plan-first math everyone quotes only holds if the plan rests on true premises. A perfectly-structured spec built on stale context fails all 20 decisions just as surely as no plan at all — it just fails them faster, and with more confidence. Every phase here was, underneath, a better answer to the same question: &lt;em&gt;where do I keep the truth the plan depends on?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build a self-hosted personal AI data platform in the open. The one design call I'm still least sure about: whether the human approval gate at Phase 3 is a permanent feature or just scaffolding I haven't automated away yet. If you've run agents against a shared queue, where did you draw that line — and did it hold?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>agents</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Adversarial Review Is Not a Vibe Check</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 09 Jun 2026 18:29:57 +0000</pubDate>
      <link>https://dev.to/niclydon/adversarial-review-is-not-a-vibe-check-39af</link>
      <guid>https://dev.to/niclydon/adversarial-review-is-not-a-vibe-check-39af</guid>
      <description>&lt;p&gt;I had a security review that was technically complete and still not good enough. The code had the controls. The tests existed. The mitigations covered the risks. The final decision was reasonable.&lt;/p&gt;

&lt;p&gt;But the review did not preserve the mapping between the adversarial prompt, the findings, the tests, and the decision. A future reader could see that the system passed its security gates, but only after reconstructing the relationship between scattered pieces of evidence.&lt;/p&gt;

&lt;p&gt;That is not a review. That is archaeology with better formatting.&lt;/p&gt;

&lt;p&gt;A vibe check can say "looks good." A real adversarial review has to prove what it looked at.&lt;/p&gt;

&lt;p&gt;So I went back and made the review explicit. Not because the code changed. It did not. The review needed to prove what it had reviewed.&lt;/p&gt;

&lt;p&gt;That was the first lesson.&lt;/p&gt;

&lt;p&gt;The second came later: once agents are doing real work, adversarial review cannot remain a markdown ritual. It needs durable workflow state. Evidence rows, handoffs, close gates, terminal markers, and a way to keep "pending review" separate from "reviewed and safe to close."&lt;/p&gt;

&lt;p&gt;The document shape mattered first.&lt;/p&gt;

&lt;p&gt;Then the backlog shape mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I am building a private connector between Nexus, my personal intelligence substrate, and Imprint, a public profile/export system.&lt;/p&gt;

&lt;p&gt;You do not need to care about the names. The boundary is the important part.&lt;/p&gt;

&lt;p&gt;Nexus contains private source material. Imprint exports structured profile data. The connector's job is not just to move records from one place to another. It has to enforce consent, privacy, replayability, auditability, and public/private separation while doing it.&lt;/p&gt;

&lt;p&gt;The Sprint 15 adversarial review started from a prompt with seven attack surfaces: config smuggling, Nexus possession as consent, dry-run output leakage, replay manifests, audit logs, connector authority creep, and the public/private boundary.&lt;/p&gt;

&lt;p&gt;The first review grouped the findings into four broad sections: config smuggling, consent enforcement, dry-run output leakage, and connector authority creep. Those were real risks. The mitigations were real. The tests were real.&lt;/p&gt;

&lt;p&gt;But three of the seven attack surfaces were only covered indirectly. Replay manifests, audit logs, and the public/private boundary were present in the evidence, but they were not named as first-class findings.&lt;/p&gt;

&lt;p&gt;That was the gap.&lt;/p&gt;

&lt;p&gt;Not a security gap in the implementation. A review-completeness gap.&lt;/p&gt;

&lt;p&gt;For adversarial review, that still matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implicit coverage is where bad reviews go to look acceptable
&lt;/h2&gt;

&lt;p&gt;You ask a system, reviewer, or agent to assess a set of risks. It comes back with plausible findings. The findings are true. The tests pass. The conclusion may even be correct.&lt;/p&gt;

&lt;p&gt;But the structure does not let you answer the basic question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did we actually review every attack surface we said we were going to review?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this case, the answer was "yes, but you have to know where to look."&lt;/p&gt;

&lt;p&gt;That is not good enough.&lt;/p&gt;

&lt;p&gt;If an adversarial prompt names seven attack surfaces, the review should preserve those seven surfaces. Each one should have a risk statement, mitigation, evidence, and disposition. If one is not applicable, say why. If one is covered by architecture rather than a unit test, name the boundary.&lt;/p&gt;

&lt;p&gt;The pattern is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;attack surface -&amp;gt; risk -&amp;gt; mitigation -&amp;gt; evidence -&amp;gt; decision&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the shape I want the review to preserve. Not because it is elegant. Because it keeps the review from compressing a specific adversarial contract into a confident paragraph.&lt;/p&gt;

&lt;p&gt;Otherwise, a future reader has to infer coverage from nearby prose. And future readers are tired, distracted, and often you six weeks later, wondering why your past self chose violence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the mapping explicit
&lt;/h2&gt;

&lt;p&gt;I reorganized the review from four broad findings into seven explicit sections, A1 through A7, matching the original adversarial prompt.&lt;/p&gt;

&lt;p&gt;The code did not change. The tests did not change. The mitigations were already there. What changed was traceability.&lt;/p&gt;

&lt;p&gt;Replay manifests are the clearest example. They were named in the adversarial prompt, but not called out as their own finding in the first review. That matters because replay artifacts need to preserve enough state to make a run reproducible without becoming private-data leaks.&lt;/p&gt;

&lt;p&gt;The improved review asks whether replay manifests leak private state or omit compatibility fields. It answers with the design: replay manifests serialize only the redacted configuration shape, including connector name, provider type, enabled families, connector version, and policy version. They do not serialize provider paths, credentials, raw record IDs, fixture names, or private source content.&lt;/p&gt;

&lt;p&gt;Then it points to &lt;code&gt;test_replay_manifest_uses_redacted_config_shape&lt;/code&gt;, which verifies that the manifest remains deterministic enough for replay compatibility while avoiding paths and record file names.&lt;/p&gt;

&lt;p&gt;That is the difference between "we probably covered replay safety under dry-run leakage" and "A4 covers replay manifests; here is the risk, mitigation, and test."&lt;/p&gt;

&lt;p&gt;Audit logs had a similar issue. They were covered under dry-run leakage, but audit logs are a different failure surface. Dry-run output is what the operator sees directly. Audit logs are what the system preserves. The updated review asks whether audit logs expose warnings, raw errors, metadata, provider details, or private record material, then points to &lt;code&gt;test_audit_log_public_safe_summary_hides_raw_text_and_paths&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The public/private boundary was covered by design, but not named clearly enough. This connector lives in a private package. Public Imprint should not import Nexus-specific code, schema assumptions, private fixtures, or pipeline authority. The mitigation is architectural: the Nexus connector remains isolated in a private package, and public Imprint imports only generic connector interfaces.&lt;/p&gt;

&lt;p&gt;That matters because not every security boundary is a runtime assertion. Some boundaries are repo shape, package ownership, import direction, and naming conventions. If the review only recognizes unit tests as evidence, it will miss those controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence is the review
&lt;/h2&gt;

&lt;p&gt;The improved review includes an evidence table mapping each attack surface to the tests or artifacts that support it.&lt;/p&gt;

&lt;p&gt;That table is not decoration. It is the review.&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%2Fc8d4inoyantynohsvy7m.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%2Fc8d4inoyantynohsvy7m.png" alt="Evidence Table" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A review should not just say "passed." It should say what passed, against which threat, with what evidence.&lt;/p&gt;

&lt;p&gt;The GO decision needs the same treatment. The original decision was accurate:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;GO for private deployment packaging after git/remote/deploy target is confirmed. The local implementation passes synthetic fixture, privacy, replay, audit, consent, and public Imprint compatibility gates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is fine as a conclusion. But conclusions are not controls.&lt;/p&gt;

&lt;p&gt;The updated review clarified each gate. Synthetic counts matched policy. Dry-run output exposed no raw text, user IDs, fixture paths, SQL, or provider internals. Replay used a deterministic redacted config shape. Audit exported only counts and a manifest reference. Consent exclusions happened before support. Public compatibility meant the Nexus connector stayed private.&lt;/p&gt;

&lt;p&gt;That turns the GO decision from a stamp into an audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents make almost-right failures easier
&lt;/h2&gt;

&lt;p&gt;This matters more once agents are doing the work.&lt;/p&gt;

&lt;p&gt;A human reviewer can be sloppy. An agent can be sloppy at machine speed, with excellent tone, confident formatting, and enough true statements to hide the gaps.&lt;/p&gt;

&lt;p&gt;The dangerous failure mode is not that the agent makes everything up. It is that the agent is almost right. It reviews five of seven risks, points to real tests, gives the right conclusion, and never tells you which requested surfaces were not explicitly mapped.&lt;/p&gt;

&lt;p&gt;That kind of output passes casual inspection.&lt;/p&gt;

&lt;p&gt;It should not pass review.&lt;/p&gt;

&lt;p&gt;A passing test suite tells you what you asserted.&lt;/p&gt;

&lt;p&gt;Adversarial review asks what you forgot to assert.&lt;/p&gt;

&lt;p&gt;The control is not "ask the model to be more critical." The control is structure.&lt;/p&gt;

&lt;p&gt;No silent merging. No broad "covered under privacy." No "looks good overall."&lt;/p&gt;

&lt;p&gt;No vibe checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the review became a workflow
&lt;/h2&gt;

&lt;p&gt;The Sprint 15 review fixed the artifact shape, but that was only half the problem.&lt;/p&gt;

&lt;p&gt;In Nexus, I also run an Operator Backlog: a Postgres-backed queue of work items that agents can investigate, implement, block, or close depending on tags, scopes, and evidence. Once agent work started moving through that backlog, adversarial review could not live only in sprint files.&lt;/p&gt;

&lt;p&gt;It needed to become a lane.&lt;/p&gt;

&lt;p&gt;That became ProjectAR.&lt;/p&gt;

&lt;p&gt;ProjectAR is a task-only adversarial review role. It does not implement the fix. It does not own the backlog. It reviews the latest investigation, handoff artifact, or close candidate produced by another role.&lt;/p&gt;

&lt;p&gt;The workflow shape is the backlog version of the document pattern:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;candidate -&amp;gt; investigation -&amp;gt; adversarial review -&amp;gt; approval or block -&amp;gt; close evidence&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In plain English: the worker can produce evidence that an item is ready to close, but a separate review lane has to decide whether that evidence is sufficient. The reviewer can approve it, block it, or route it somewhere else. That decision has to survive as part of the item history.&lt;/p&gt;

&lt;p&gt;Otherwise, "implemented" quietly becomes "reviewed."&lt;/p&gt;

&lt;p&gt;The tag model mattered more than I expected. I ended up with two concepts: &lt;code&gt;adversarial_review&lt;/code&gt; as the active pending lane, and &lt;code&gt;adversarial_reviewed&lt;/code&gt; as the terminal marker that says the review happened.&lt;/p&gt;

&lt;p&gt;The active tag asks whether a review role should pick this up. The terminal marker records whether the item passed through review before closure.&lt;/p&gt;

&lt;p&gt;The backlog needs both because those are different states. If the same marker means "needs review" and "was reviewed," the queue cannot tell whether an item is pending, blocked, approved, or merely carrying historical evidence. If the active tag disappears at closure without a terminal marker replacing it, the system loses proof that the review ever happened.&lt;/p&gt;

&lt;p&gt;That is the workflow version of implicit coverage.&lt;/p&gt;

&lt;p&gt;At the document layer, the review covered the risk, but the mapping was not obvious. At the workflow layer, the item may have been reviewed, but the state did not make that visible.&lt;/p&gt;

&lt;p&gt;Both make future readers trust that the system did the right thing because the artifact no longer proves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bar
&lt;/h2&gt;

&lt;p&gt;No code changed in the Sprint 15 review-completeness pass. No tests changed. No mitigation changed.&lt;/p&gt;

&lt;p&gt;The review changed.&lt;/p&gt;

&lt;p&gt;That sounds cosmetic until you need to rely on the review later. Then it becomes the difference between "I think we looked at that" and "A5 covers audit logs; here is the exact test and why the GO decision includes it."&lt;/p&gt;

&lt;p&gt;ProjectAR pushed the same idea into the backlog. It made adversarial review visible as operational state, not just a section heading.&lt;/p&gt;

&lt;p&gt;That is the bar I want for agent-assisted review.&lt;/p&gt;

&lt;p&gt;Not perfect certainty. Not theater. Not a longer markdown file because security people enjoy suffering.&lt;/p&gt;

&lt;p&gt;A traceable adversarial review.&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%2Fs14r0idto3089bj495ot.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%2Fs14r0idto3089bj495ot.png" alt="Agent Adversarial Review" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One where every claimed boundary has evidence, every requested attack surface has a disposition, every reviewed backlog item preserves its review state, and the final decision can be reconstructed without trusting the reviewer's confidence.&lt;/p&gt;

&lt;p&gt;Because confidence is cheap.&lt;/p&gt;

&lt;p&gt;Mapping is the control.&lt;/p&gt;

&lt;p&gt;And state is what keeps the control alive after the markdown scrolls offscreen.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>My agent swarm had a productive night. My pipeline lied about it.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Fri, 05 Jun 2026 14:13:54 +0000</pubDate>
      <link>https://dev.to/niclydon/my-agent-swarm-had-a-productive-night-my-pipeline-lied-about-it-1kao</link>
      <guid>https://dev.to/niclydon/my-agent-swarm-had-a-productive-night-my-pipeline-lied-about-it-1kao</guid>
      <description>&lt;p&gt;I gave a Grok CLI agent swarm one instruction around 1am and went to bed. By 5:30 it had closed 37 items off my operator backlog and landed 20 commits on main: 2,838 lines added, 112 removed, across 51 files. Good night’s work for a process I wasn’t awake for.&lt;/p&gt;

&lt;p&gt;Then I tried to audit it, and discovered my own ingest pipeline had quietly turned the entire run into garbage. The schema mismatch is mundane. The lesson underneath it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I run a personal automation stack with an operator backlog: a Postgres-backed queue of tickets tagged by whether an agent can safely act on them unattended. My usual agents drain that queue against a set of contracts: an investigator role that diagnoses, and a worker role that ships with test gates. I wanted to see how a Grok CLI swarm would handle the same contracts.&lt;/p&gt;

&lt;p&gt;The kickoff was one line: keep working through the queue, do the autonomous-safe work. The first session fanned out a dozen subagents in about three seconds. Each read the same prompt contracts, and each claimed tickets with a lease so they wouldn’t collide.&lt;/p&gt;

&lt;p&gt;Every session writes a transcript to disk. My stack ingests those transcripts into Postgres so I can answer questions like “what did agent #7 actually run.” Standard local-first observability: the model provider doesn’t hold my data, I do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The morning
&lt;/h2&gt;

&lt;p&gt;I went to look at the per-session breakdown and the numbers were obviously wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_subagent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subagents&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;grok_sessions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; sessions | with_model | turns | subagents
----------+------------+-------+-----------
       95 |          0 |    99 |         0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety-five sessions, zero with a model recorded, ninety-nine total turns, zero subagents. That immediately failed a sanity check: six sessions had &lt;code&gt;subagent-&amp;lt;uuid&amp;gt;&lt;/code&gt; in their project name, and a dozen subagents launching in three seconds do not average one turn each. The data was lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transcript format
&lt;/h2&gt;

&lt;p&gt;The importer was written against a Claude-Code-shaped assumption. It expected a &lt;code&gt;role&lt;/code&gt; field on each record, tool calls embedded inside content blocks, and a model field somewhere on the message. Here is what Grok CLI actually writes, one JSON object per line:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;chat_history.jsonl | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.type'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
     47 assistant
     47 reasoning
      1 system
     51 tool_result
      3 user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things break immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no &lt;code&gt;role&lt;/code&gt;.&lt;/strong&gt; The discriminator is &lt;code&gt;type&lt;/code&gt;, and the values include &lt;code&gt;reasoning&lt;/code&gt; and &lt;code&gt;tool_result&lt;/code&gt;, record types the parser had never heard of. Anything that wasn’t &lt;code&gt;system&lt;/code&gt; or &lt;code&gt;assistant&lt;/code&gt; got bucketed into &lt;code&gt;user&lt;/code&gt;. So a 149-record transcript with 47 real assistant turns counted as roughly three turns, the actual user messages. Everything else disappeared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool calls aren’t embedded in content blocks.&lt;/strong&gt; They’re a top-level array on assistant records:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;chat_history.jsonl | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.type=="assistant")
    | {model_id, tools: (.tool_calls | length), first: .tool_calls[0].name}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"model_id"&lt;/span&gt;:&lt;span class="s2"&gt;"grok-build"&lt;/span&gt;,&lt;span class="s2"&gt;"tools"&lt;/span&gt;:2,&lt;span class="s2"&gt;"first"&lt;/span&gt;:&lt;span class="s2"&gt;"read_file"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"model_id"&lt;/span&gt;:&lt;span class="s2"&gt;"grok-build"&lt;/span&gt;,&lt;span class="s2"&gt;"tools"&lt;/span&gt;:3,&lt;span class="s2"&gt;"first"&lt;/span&gt;:&lt;span class="s2"&gt;"read_file"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"model_id"&lt;/span&gt;:&lt;span class="s2"&gt;"grok-build"&lt;/span&gt;,&lt;span class="s2"&gt;"tools"&lt;/span&gt;:2,&lt;span class="s2"&gt;"first"&lt;/span&gt;:&lt;span class="s2"&gt;"grep"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser scanned content blocks looking for tool-use records that never existed, so every session appeared to have made zero tool calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model was right there.&lt;/strong&gt; Every assistant record carried &lt;code&gt;"model_id": "grok-build"&lt;/code&gt;. The importer had a &lt;code&gt;state.model&lt;/code&gt; variable. It declared it, threaded it through the insert path, and never assigned it. A dead variable writing NULL ninety-five times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real failure
&lt;/h2&gt;

&lt;p&gt;The real failure wasn’t misreading the transcript. It was assuming the transcript was the source of truth. The parser bug merely exposed the design flaw.&lt;/p&gt;

&lt;p&gt;In hindsight the warning signs were obvious. Every session directory already contained multiple independent records of what happened: transcripts, event streams, session metadata, and git state. I had accidentally built an audit pipeline that ignored corroborating evidence and trusted the narrative, so when the narrative parser failed, the entire picture failed with it.&lt;/p&gt;

&lt;p&gt;The session metadata alone contained everything I was missing:&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;"current_model_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grok-build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subagent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"num_chat_messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;149&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-05T00:28:06.848Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_active_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-05T00:33:48.184Z"&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;Every missing column was already available. &lt;code&gt;session_kind&lt;/code&gt; identifies subagents, &lt;code&gt;num_chat_messages&lt;/code&gt; is the real turn count, &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;last_active_at&lt;/code&gt; are the real timestamps, and additional metadata links the session back to the correct repository context. Meanwhile my importer was deriving timestamps from file modification times and inferring project identity from directory names.&lt;/p&gt;

&lt;p&gt;The fix is not a patch to the parse loop. It’s architectural: trust authoritative session metadata for session-level facts, use transcripts only for message bodies, handle the record types that actually exist, and corroborate one source against another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I’m telling on myself
&lt;/h2&gt;

&lt;p&gt;Here’s the uncomfortable bit. I could not answer “what did the swarm actually do” from the swarm’s own records. I had to reconstruct the night from two sources the agents didn’t write: the backlog table, which recorded what changed state, and git history, which recorded what actually landed. Those two agreed, and they’re trustworthy precisely because the agents couldn’t rewrite them afterward.&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="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-04 23:30"&lt;/span&gt; &lt;span class="nt"&gt;--until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-05 08:00"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tformat: &lt;span class="nt"&gt;--numstat&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NF==3 {a+=$1; d+=$2; f++}
         END {printf "%d files, +%d/-%d\n", f, a, d}'&lt;/span&gt;
51 files, +2838/-112
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That number I believe. The transcript-derived “99 turns” I don’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;This has nothing to do with Grok specifically. Autonomous agents are trivial to launch and genuinely hard to audit, and the gap between those two facts is where teams are going to get hurt over the next few years. An agent telling you what it did is not evidence. It’s a claim, and it’s a claim sourced from the least trustworthy possible place: the thing being audited.&lt;/p&gt;

&lt;p&gt;I filed two tickets against my own importer. One fixes the parser; the other forces a re-parse of the 95 corrupted rows, since the incremental loader skips files whose size hasn’t changed and a logic fix doesn’t change the file. They’ll get fixed. But the design flaw matters more than the bug: verify agent work from sources the agent cannot write to, and preferably from multiple sources that must agree. Otherwise you’re not auditing behavior, you’re auditing a story about behavior.&lt;/p&gt;

&lt;p&gt;If your only record of an agent’s actions comes from a channel the agent controls, you don’t have observability. You have a press release with a timestamp.&lt;/p&gt;

&lt;p&gt;The swarm had a productive night. I just had to prove it the hard way.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>buildinpublic</category>
      <category>llm</category>
    </item>
    <item>
      <title>GitHub Got Breached Through a VS Code Extension. MCP Servers Are Next.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Wed, 20 May 2026 14:01:08 +0000</pubDate>
      <link>https://dev.to/niclydon/github-got-breached-through-a-vs-code-extension-mcp-servers-are-next-5dgc</link>
      <guid>https://dev.to/niclydon/github-got-breached-through-a-vs-code-extension-mcp-servers-are-next-5dgc</guid>
      <description>&lt;p&gt;Yesterday, GitHub said it had detected and contained a compromise of an employee device involving a poisoned VS Code extension. The company said its current assessment is that the activity involved exfiltration of GitHub-internal repositories only, and that the attacker's claim of roughly 3,800 repositories is directionally consistent with its investigation so far. GitHub removed the malicious extension, isolated the endpoint, and prioritized rotation of critical credentials.&lt;/p&gt;

&lt;p&gt;A few days earlier, I had been doing something similar from the other direction. I yanked OpenAI's Codex Chronicle off my laptop and replaced it with a local Gemma 4 instance running on a Mac mini I own. Originally, that was a cost decision. The breach made the security implications of the architecture impossible to ignore.&lt;/p&gt;

&lt;p&gt;A trusted third-party binary. Installed locally. Full read access to your screen, your files, your tokens. An outbound network path the user set up themselves, allowed by every firewall because the user did it.&lt;/p&gt;

&lt;p&gt;Compromise the binary at any point in its supply chain, and you do not need to compromise the platform. The platform is doing what it was told.&lt;/p&gt;

&lt;p&gt;You walked in.&lt;/p&gt;

&lt;p&gt;That is the GitHub breach. That is also Codex Chronicle if a tool like it were ever compromised at the build pipeline or distribution layer. The architectures are siblings.&lt;/p&gt;

&lt;p&gt;A local model is not automatically safe. A compromised local agent with filesystem and shell access is still a privileged execution environment. The difference is that local architectures reduce the mandatory external trust boundary and make inspection possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning signs were already everywhere
&lt;/h2&gt;

&lt;p&gt;TeamPCP is not a one-off. The group is tracked in public reporting as UNC6780, and its 2026 target list before this week included Trivy, Checkmarx, LiteLLM, the Bitwarden CLI, PyTorch Lightning, and most recently the TanStack and durabletask npm and PyPI compromises connected to the Grafana breach a week earlier.&lt;/p&gt;

&lt;p&gt;Look at that list.&lt;/p&gt;

&lt;p&gt;Trivy. Checkmarx. LiteLLM. Bitwarden CLI. PyTorch Lightning.&lt;/p&gt;

&lt;p&gt;Every one of those is developer or developer-adjacent tooling. Trusted. Locally installed. Frequently updated. Each one is a trojan vector with a credential blast radius that extends from the developer's laptop through cloud accounts and into production.&lt;/p&gt;

&lt;p&gt;The npm side of the same campaign is worse. According to public analysis from SlowMist and other researchers, attackers compromised the npm account &lt;code&gt;atool&lt;/code&gt; and pushed hundreds of malicious package versions across hundreds of packages within minutes. The Mini Shai-Hulud malware family specifically targets GitHub tokens, AWS keys, Kubernetes secrets, SSH credentials, password manager databases, and local crypto wallet files.&lt;/p&gt;

&lt;p&gt;This is not a string of bad luck. It is a deliberate, sustained campaign against the supply chain that ends at every developer's &lt;code&gt;~/.config&lt;/code&gt;, &lt;code&gt;~/.aws&lt;/code&gt;, and &lt;code&gt;~/.ssh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The developer endpoint is the perimeter
&lt;/h2&gt;

&lt;p&gt;For roughly a decade, the prevailing security model has been: trust the developer, harden the platform.&lt;/p&gt;

&lt;p&gt;Endpoint security on engineering laptops is usually a thin layer: corporate EDR, maybe a DLP agent, maybe MDM. The real controls live around code review, CI/CD policy, and production access.&lt;/p&gt;

&lt;p&gt;That model is over.&lt;/p&gt;

&lt;p&gt;The developer endpoint is the highest-privilege, least-monitored node in most environments. It has SSH keys to servers. It has cloud CLI tokens with broad blast radius. It has GitHub credentials with push access to repos that ship to production. It has unencrypted source code.&lt;/p&gt;

&lt;p&gt;And increasingly, it is running a fleet of trusted third-party processes the security team has never reviewed.&lt;/p&gt;

&lt;p&gt;VS Code extensions are one example. Most developer environments have dozens. Each one runs with the developer's full user-level privilege. Each one can read any file the developer can read.&lt;/p&gt;

&lt;p&gt;MCP servers and AI coding agents inherit the same trust model almost verbatim: local execution, broad filesystem visibility, ambient credentials, user-approved outbound network access, and a supply chain most organizations do not inspect.&lt;/p&gt;

&lt;p&gt;I run more than thirty MCP servers connected to my own development environment. I built several of them. I trust myself.&lt;/p&gt;

&lt;p&gt;I do not, in the strict sense, trust the supply chain of every dependency every one of them pulls in.&lt;/p&gt;

&lt;p&gt;Almost nobody does.&lt;/p&gt;

&lt;p&gt;The industry adopted AI-assisted developer tooling with the operational rigor of browser extensions, not privileged infrastructure. Convenience won faster than trust modeling caught up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What mechanical enforcement actually looks like
&lt;/h2&gt;

&lt;p&gt;The fix is not a memo telling developers to be careful. Telling a tired engineer at midnight to audit their extension list is not a control. It is a wish.&lt;/p&gt;

&lt;p&gt;The fix is mechanical enforcement that runs whether the developer is paying attention or not.&lt;/p&gt;

&lt;p&gt;In my own development setup, that looks like four layers. None of them are clever. All of them are boring.&lt;/p&gt;

&lt;p&gt;Boring is the point.&lt;/p&gt;

&lt;p&gt;Layer one: pre-commit hooks. Every commit, in every repo, runs a Python scanner before the commit is allowed to complete. The scanner has specific patterns for OpenAI, Anthropic, Google, GitHub, Slack, Discord webhooks, AWS access keys, and a dozen other token shapes, plus raw &lt;code&gt;.env&lt;/code&gt; and certificate/private-key file detection. It excludes example and placeholder shapes to keep the false positive rate low. If a real secret is staged, the commit blocks. The hook does not care if the developer noticed.&lt;/p&gt;

&lt;p&gt;Layer two: agent hooks. Claude Code and Codex both expose hooks that can fire before file writes and command execution. I run the same scanner against proposed edits. The agent cannot persist a secret to disk through the normal write path, because the hook denies the operation before the write happens.&lt;/p&gt;

&lt;p&gt;This catches genuine mistakes, like an agent paraphrasing a &lt;code&gt;.env&lt;/code&gt; it read into the next file it writes. It also catches credential reconnaissance. A Bash command that greps for &lt;code&gt;password&lt;/code&gt; across the repo or cats an &lt;code&gt;.env&lt;/code&gt; is blocked at the tool-call boundary, not at the application layer.&lt;/p&gt;

&lt;p&gt;I have actual logs of this firing. During a real OAuth flow earlier this spring, an agent tried to retrieve a shared password by probing the local vault directory, running &lt;code&gt;systemctl cat&lt;/code&gt;, and grepping across config files.&lt;/p&gt;

&lt;p&gt;Five Bash calls. Five denials.&lt;/p&gt;

&lt;p&gt;Each one cited the security policy by name. The correct path was for the user to explicitly authorize retrieval through the canonical vault command, which is exactly what eventually happened.&lt;/p&gt;

&lt;p&gt;The hook did its job.&lt;/p&gt;

&lt;p&gt;Layer three: &lt;code&gt;.claudeignore&lt;/code&gt;. Every repo on my development machine has one. It is the agent equivalent of &lt;code&gt;.gitignore&lt;/code&gt;. It prevents the AI tool from loading sensitive paths into context in the first place. The list is uncontroversial: &lt;code&gt;.env*&lt;/code&gt; files, certificate and key shapes, raw database files, build output, editor metadata. If the agent never sees the secret, it cannot accidentally leak it into a draft, summary, or commit.&lt;/p&gt;

&lt;p&gt;Layer four: a single secrets vault. All real credentials live in AWS Secrets Manager, in a dedicated account, accessed through a single CLI. Application code, agent tools, and CI all pull from the vault at runtime. Source code commits placeholders only. Rotating a credential means updating it in one place. If a secret is ever leaked, it can be rotated globally in seconds, not by tracking down every config file that ever held a copy.&lt;/p&gt;

&lt;p&gt;The line in my own security policy doc reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hook failures are security findings, not lint style.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the mechanical layer catches an actual secret, it is rotated. The hook is not asking for permission to be turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;This stack would not have stopped the GitHub compromise.&lt;/p&gt;

&lt;p&gt;A poisoned VS Code extension running with the developer's full privilege has its own pathway. It does not need to commit anything. It does not need to call the agent's write path. It can read tokens directly from disk, hit any network endpoint, and exfiltrate immediately.&lt;/p&gt;

&lt;p&gt;None of my hooks would see it, because it does not flow through any of my hook points.&lt;/p&gt;

&lt;p&gt;That is the honest part. The mechanical layer is defense in depth, not a wall.&lt;/p&gt;

&lt;p&gt;What the stack does do is harden the common failure modes: the developer who pastes a real key into a commit, the agent that helpfully echoes a secret back into a file, the credential probe an attacker uses as next-step reconnaissance after initial compromise.&lt;/p&gt;

&lt;p&gt;It removes easy mistakes. It logs attempts. It makes privileged paths visible.&lt;/p&gt;

&lt;p&gt;The harder problem is the one the GitHub breach is screaming about: the supply chain that delivers third-party code to a developer's local environment has almost no security model.&lt;/p&gt;

&lt;p&gt;There is no meaningful review for VS Code extensions beyond "removed after it was reported." There is no meaningful review for MCP servers beyond "trust the maintainer." There is no enforced signing requirement, no provenance attestation requirement, and no runtime sandbox that most teams can rely on.&lt;/p&gt;

&lt;p&gt;If you are a security leader, the actionable question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What did the attackers do to GitHub?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actionable question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What is the inventory of third-party processes running with developer privilege in my environment, and what would happen if any single one of them was compromised this afternoon?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For most organizations, the answer is not encouraging.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is coming
&lt;/h2&gt;

&lt;p&gt;I expect three things in the next twelve months.&lt;/p&gt;

&lt;p&gt;First, the major platforms will harden. GitHub and Microsoft will tighten VS Code extension publishing controls. Anthropic and OpenAI will add provenance signatures to their tool ecosystems. npm and PyPI will likely see at least one more wave before the registry-level changes that need to happen actually happen.&lt;/p&gt;

&lt;p&gt;Second, MCP servers will get their first major incident. The trust model is wrong. The attack surface is large. The defender side has barely started. Someone will write a malicious MCP server that behaves like a malicious VS Code extension, and it will run for weeks before anyone notices.&lt;/p&gt;

&lt;p&gt;Third, the conversation about endpoint security on engineering laptops will move from "EDR plus a memo" to "your dev environment is a production system." The organizations that get to that mental model first will spend the next eighteen months less expensively than the ones that wait for their own TeamPCP moment.&lt;/p&gt;

&lt;p&gt;The Codex Chronicle install I pulled last weekend is a small example of the right reflex. The thing I removed was not malicious. It was a legitimate research preview from a major lab.&lt;/p&gt;

&lt;p&gt;I removed it because it had the wrong architecture for what I wanted: a local capability that periodically used recent screen context through a cloud service, a binary I could not audit, and a network path I did not need open.&lt;/p&gt;

&lt;p&gt;The replacement runs on hardware I own, in a way I can inspect, without a mandatory outbound dependency.&lt;/p&gt;

&lt;p&gt;That architecture choice is what more developer tooling should default to in 2026. Not local because of privacy theater. Local because the trust profile is smaller and the failure modes are more visible.&lt;/p&gt;

&lt;p&gt;The cloud round trip should be reserved for cases where the cloud is genuinely necessary, not cases where the vendor wants recurring usage.&lt;/p&gt;

&lt;p&gt;The GitHub breach made the security case for that reflex more obvious than any threat model I could have drawn on a whiteboard.&lt;/p&gt;

&lt;p&gt;The interesting question is not whether security teams agree with this in principle. Most will, when asked.&lt;/p&gt;

&lt;p&gt;The interesting question is whether they have enforcement that runs whether anyone agrees with it or not.&lt;/p&gt;

&lt;p&gt;If your developer endpoints are running on policy memos, the next year is going to be expensive.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>ai</category>
      <category>github</category>
    </item>
    <item>
      <title>Capture the Reasoning Path, Not the Final State</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 19 May 2026 23:46:00 +0000</pubDate>
      <link>https://dev.to/niclydon/capture-the-reasoning-path-not-the-final-state-c9d</link>
      <guid>https://dev.to/niclydon/capture-the-reasoning-path-not-the-final-state-c9d</guid>
      <description>&lt;p&gt;Two files, one discipline, and a measured 10-13% of my Claude Code budget.&lt;/p&gt;

&lt;p&gt;A while back, mid-session with Claude Code, I typed a pushback in the kind of broken English you only produce past midnight:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"are we using full netflix level doc uodsyed as ws go here ?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I meant: are we updating documentation at full Netflix-documentary depth as we go, or are we doing the lazy version that just records what changed without why? Claude correctly inferred the Netflix version. From that point forward the documentation standard for every one of my projects was set.&lt;/p&gt;

&lt;p&gt;That session became the basis for what I now call paper-trail: a portable ruleset that makes Claude Code (and any other AI coding agent that respects CLAUDE.md) write documentation at documentary depth instead of git-log depth.&lt;/p&gt;

&lt;p&gt;This post is about why that matters more when an AI is doing most of the typing, and what the discipline actually looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reasoning path is what AI loses
&lt;/h2&gt;

&lt;p&gt;Most documentation captures the final state. The README says what the system does. The CHANGELOG says what version shipped. The commit message says what file changed.&lt;/p&gt;

&lt;p&gt;What disappears at session end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The three alternatives you considered before picking option C&lt;/li&gt;
&lt;li&gt;The operator pushback that killed your original design&lt;/li&gt;
&lt;li&gt;The verification log that convinced you the fix worked&lt;/li&gt;
&lt;li&gt;The false start at 11pm that explains the weird workaround at line 240&lt;/li&gt;
&lt;li&gt;The dependency you didn't realize existed until something broke&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you're writing code yourself, this knowledge lives in your head, badly, for about a week. After that it's gone.&lt;/p&gt;

&lt;p&gt;When an AI agent is doing most of the typing, the gap gets worse. The agent has zero memory of the rejected alternatives. Six months later it confidently suggests a fix you already turned down. There is no record of why you turned it down.&lt;/p&gt;

&lt;p&gt;The reasoning path is what makes future debugging possible. AI makes it more valuable and more fragile at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two files, one discipline
&lt;/h2&gt;

&lt;p&gt;The structure is simple.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CHANGES.md&lt;/code&gt; at the repo root. Chronological log, newest entry on top. Updated as work happens, not after. Each entry covers what changed, why, what was decided, what was rejected, how it was verified, and what's still outstanding.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/narrative/&amp;lt;YYYY-MM-DD&amp;gt;-&amp;lt;topic&amp;gt;.md&lt;/code&gt; for the bigger arcs. Migrations, incidents, rewrites, source onboarding. Starting state, trigger, decisions, rejected alternatives, phases, verification, final state, what's unblocked.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CHANGES.md&lt;/code&gt; is the index. &lt;code&gt;docs/narrative&lt;/code&gt; is the story.&lt;/p&gt;

&lt;p&gt;Both are plain markdown. Both get committed. Both are designed to be grep-able by your tools and your future agent.&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%2Ftf6oyhwfqrihm9539yoe.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%2Ftf6oyhwfqrihm9539yoe.png" alt="paper-trail" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A real CHANGES.md entry
&lt;/h2&gt;

&lt;p&gt;Here's the entry from a Music sync resurrection a few weeks ago (anonymized identifiers, real structure):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;2026-05-16: Music sync row restored in iOS Settings view

After the backend consolidation on 2026-05-04, the iOS Settings view lost the row that exposed Music sync to users. The sync pipeline itself was intact in the backend; only the toggle had been removed during the cleanup.

Restored via 6 lines in App/Views/SettingsView.swift, adding the row back under "Data Sources." TestFlight build 47 ships the restored row. Verified end-to-end by pulling a fresh sync from the device and confirming the delivery UUID 8b4f2a9c-7d15-4e83-9bcd-12fa8e5c61d4 landed in the backend.

Decided: restore the row as-is rather than redesign the Settings view (the consolidation rationale doesn't apply to this row).
Rejected: moving Music to a dedicated "Media" section. Too much surface area to redesign for one source.
Outstanding: wire the new Qwen commit a3f2c8e91 for next week's audio path.

commit e74b2c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One paragraph plus a six-line code block plus four metadata lines. Names the dormant pipeline, the build that shipped, the cross-repo dependency, the rejected alternative.&lt;/p&gt;

&lt;p&gt;That's the index entry. The narrative doc tells the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same event as a narrative doc
&lt;/h2&gt;

&lt;p&gt;Title: "The Settings Row That Brought Music Back" at &lt;code&gt;docs/narrative/2026-05-16-music-resurrection.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Trigger:&lt;/strong&gt; what made us notice Music sync was dark (a test query returned zero rows from a source that should have been daily)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Diff:&lt;/strong&gt; what the original consolidation actually removed, with the line numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What Almost Happened:&lt;/strong&gt; the redesign-the-whole-view path I considered before realizing six lines of Swift was the answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; the delivery UUID that proved the path was wired back end-to-end&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's Unblocked:&lt;/strong&gt; the audio path work that depended on Music being live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It reads like a documentary episode. Tradeoffs, false starts, operator decisions, verification numbers. Anyone (including a future me, including a future agent) can reconstruct the reasoning path from the doc alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day-one install
&lt;/h2&gt;

&lt;p&gt;The whole thing is at &lt;code&gt;github.com/niclydon/paper-trail&lt;/code&gt;. MIT-licensed, drop-in.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copy &lt;code&gt;DOCUMENTARY_STYLE_DOCUMENTATION.md&lt;/code&gt; into your project's root (e.g. &lt;code&gt;~/projects/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;In your top-level &lt;code&gt;CLAUDE.md&lt;/code&gt;, add &lt;code&gt;@DOCUMENTARY_STYLE_DOCUMENTATION.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In each project's &lt;code&gt;CLAUDE.md&lt;/code&gt;, paste the per-project boilerplate from &lt;code&gt;templates/per-project-boilerplate.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create an empty &lt;code&gt;CHANGES.md&lt;/code&gt; at each project root.&lt;/li&gt;
&lt;li&gt;For the first non-trivial migration or incident, create a narrative doc using the skeleton.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude will start appending &lt;code&gt;CHANGES.md&lt;/code&gt; entries on its next session in that tree.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;-LITE&lt;/code&gt; variant of the ruleset (~75% smaller, same discipline) for sub-agents or context-tight sessions.&lt;/p&gt;

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

&lt;p&gt;The honest answer requires two measurements.&lt;/p&gt;

&lt;p&gt;The first: when I check &lt;code&gt;/status&lt;/code&gt; in Claude Code, my &lt;code&gt;/narrative-docs-update&lt;/code&gt; slash command shows up at about 9% of my weekly Claude Pro Max plan usage. That's the cleanly attributable cost. Every time I deliberately invoke the skill to write or update a narrative doc, it adds to that bucket.&lt;/p&gt;

&lt;p&gt;The second is harder to measure. &lt;code&gt;CHANGES.md&lt;/code&gt; appends happen inline during regular sessions, not as a separate skill invocation. They blend into general usage and don't show up as a line item in &lt;code&gt;/status&lt;/code&gt;. The only way to measure them is to look at the content itself.&lt;/p&gt;

&lt;p&gt;So I ran the math. Across 158,000 Claude Code messages from 1,737 sessions over the last 30 days, I summed the character count of all assistant output that referenced &lt;code&gt;CHANGES.md&lt;/code&gt;, &lt;code&gt;docs/narrative/&lt;/code&gt;, or &lt;code&gt;docs/migrations/&lt;/code&gt; paths. The result: 1.16 million characters out of 9.2 million total. 12.6% of Claude Code's written output over 30 days went to documentation work.&lt;/p&gt;

&lt;p&gt;The two measurements converge. 9% is the floor, cleanly counted from a dedicated skill. 12.6% is the broader signal that catches inline doc work too. Call it 10-13% of Claude Code output.&lt;/p&gt;

&lt;p&gt;The part that surprised me: the discipline isn't applied uniformly. Only 11.5% of my sessions involved documentation work at all. The other 88.5% never touched a &lt;code&gt;CHANGES.md&lt;/code&gt; or narrative doc. They're quick queries, exploration, one-offs.&lt;/p&gt;

&lt;p&gt;Where documentation work shows up is in the substantial sessions. The ones where I actually built or migrated or debugged something worth recording. Doc-meaningful sessions average about 50,000 characters of assistant output. No-docs sessions average about 850. Documentation effort scales with work effort, which is what you want.&lt;/p&gt;

&lt;p&gt;Three things make 10-13% the easiest spend in my Claude Code plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The output is durable.&lt;/strong&gt; The other ~88% of Claude's output is ephemeral chat that disappears when the session ends. That ~12% is markdown files that persist, get committed, and become referenceable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The agent doesn't remember what it built.&lt;/strong&gt; Without these docs, the next session has no idea what was rejected, why, or with what verification. Reconstructing reasoning later costs more than recording it now.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A recent debug story made the case concrete.&lt;/strong&gt; A few weeks ago an iOS pipeline went dark after a backend consolidation. The &lt;code&gt;CHANGES.md&lt;/code&gt; entry from the original consolidation told me exactly which row had been removed from the Settings view and why. Without that record I'd have spent an hour trace-debugging. With it: six lines of Swift to restore.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cost of the discipline is small. The cost of skipping it shows up when you need the record and it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Two files, one discipline, ten-to-thirteen percent of my Claude Code budget. In exchange: a searchable record of every non-trivial decision, the rejection rationale for the alternatives, and verification numbers that survive every session reset.&lt;/p&gt;

&lt;p&gt;AI doesn't remove the need for documentation. It makes the reasoning path both more valuable (because the agent does more of the typing) and more fragile (because the agent forgets everything when the session ends).&lt;/p&gt;

&lt;p&gt;If you're going to let an AI write most of your code, give it (and yourself) a paper trail.&lt;/p&gt;




&lt;p&gt;Drop-in repo: &lt;a href="https://github.com/niclydon/paper-trail" rel="noopener noreferrer"&gt;github.com/niclydon/paper-trail&lt;/a&gt;. MIT-licensed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>documentation</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Codex Chronicle was paying for every frame.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Mon, 18 May 2026 20:54:32 +0000</pubDate>
      <link>https://dev.to/niclydon/codex-chronicle-was-paying-for-every-frame-i-built-a-four-sensor-gemma-4-replacement-on-a-mac-mini-55e7</link>
      <guid>https://dev.to/niclydon/codex-chronicle-was-paying-for-every-frame-i-built-a-four-sensor-gemma-4-replacement-on-a-mac-mini-55e7</guid>
      <description>&lt;p&gt;I built a four-sensor Gemma 4 replacement on a Mac mini.&lt;/p&gt;

&lt;p&gt;For about a week I had OpenAI’s research-preview Chronicle running on my MacBook. Every ten minutes it screenshotted my display, uploaded frames to OpenAI for analysis, and wrote Markdown summaries on my Mac. I was crawling that folder and ingesting the data in a Postgres table on my homelab.&lt;/p&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;It also cost credits for every cycle of attention.&lt;/p&gt;

&lt;p&gt;This weekend I replaced it with a single Gemma 4 E4B 4-bit MLX instance running on a $599 Mac mini, summarizing four independent sensor streams locally with zero outbound LLM calls and effectively zero marginal inference cost.&lt;/p&gt;

&lt;p&gt;OpenAI describes the constraints plainly in their own documentation: screen captures are uploaded to OpenAI’s servers for processing, the feature “uses rate limits quickly,” it “increases risk of prompt injection,” memories are stored as “unencrypted Markdown files” on the user’s machine, and it is unavailable in the EU, UK, and Switzerland. Chronicle is a Pro-tier feature on a Pro-tier price. The architectural choice is honest: cloud inference, per-frame cost, the model belongs to OpenAI.&lt;/p&gt;

&lt;p&gt;I wanted a different shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;This weekend I replaced Chronicle. Not with a better cloud service. With a single Gemma 4 E4B 4-bit MLX instance on a $599 Mac mini, summarizing video from four sensors (my screen, a wearable camera, the security cameras in my living room, and the wearable’s realtime AI commentary) and writing them all to one Postgres table, redacted at ingest, queryable in SQL. Zero outbound LLM calls. Zero per-frame cost.&lt;/p&gt;

&lt;p&gt;The same model instance also serves the rest of my homelab’s vision workloads.&lt;/p&gt;

&lt;p&gt;The marginal cost of adding the fifth sensor (which is already in a box on the way) is whatever shipping cost was paid for a Raspberry Pi Zero 2 W.&lt;/p&gt;

&lt;p&gt;This is the sequel to a piece I published five days ago about putting Gemma 4 behind my homelab AI gateway. That one ended with: “Anvil is not just a dev box. For some multimodal work, it is a useful inference target.” This is about Anvil graduating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Gemma 4 E4B specifically
&lt;/h2&gt;

&lt;p&gt;The reasoning, in order of how much each one mattered to me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Native multimodal in one checkpoint.&lt;/strong&gt; Image AND video AND audio paths in the same file. The whole sensor mesh runs through one weights load. No model swap per input type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;16 GB of unified memory is enough.&lt;/strong&gt; The 4-bit MLX build sits at about 6 GB peak resident in isolation, around 8.5 GB under co-tenant load. On a base M-series Mac mini that leaves comfortable headroom for the OS, the FastAPI daemon, and a menubar app to watch it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apache 2.0 weights.&lt;/strong&gt; The model file is on my machine. Nobody can deprecate it out from under me, reprice it overnight, or restrict it by jurisdiction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It’s already loaded.&lt;/strong&gt; I was routing this exact model through Forge for unrelated work. Spinning a second model for Logbook specifically would have been waste. One Gemma 4 instance. Two production roles.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Four sensors, one envelope
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  [MacBook Screen]   [Looki Wearable]   [Blink Cameras]
         │                  │                  │
         └──────────────────┼──────────────────┘
                            ▼
                   [Logbook Producers]
                            │
                            ▼
                  [Anvil / Gemma 4 E4B]
                            │
                            ▼
                    [Redaction Layer]
                            │
                            ▼
                       [Postgres]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Logbook row is an &lt;code&gt;observation.event.v1&lt;/code&gt; envelope. The schema fits in one paragraph: a deterministic UUIDv5, a source enum, a captured_at timestamp, a clip duration_s, optional frame_count, an image_summary, an optional video_summary, a media_uri for the staging location, an inference_metadata blob, and a source_metadata blob. Same schema, four producers.&lt;/p&gt;

&lt;p&gt;The producers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MacBook screen.&lt;/strong&gt; A Python capture daemon running as a LaunchAgent. Records a short screen video on a fixed cadence, pauses when HID idle exceeds 10 minutes, POSTs the clip to Anvil for analysis, then POSTs the resulting envelope to the homelab ingest endpoint.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Looki wearable (clips).&lt;/strong&gt; A worker polls the wearable’s cloud, stages new motion clips to local NVMe, runs them through the same Anvil daemon.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&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%2Fmclbfrfymfo7tv3m2hr0.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%2Fmclbfrfymfo7tv3m2hr0.png" alt="Looki Ingest" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Looki wearable (realtime).&lt;/strong&gt; The wearable emits realtime AI commentary as text events. A second worker forwards those as image-summary-only observations into the same table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Blink security cameras.&lt;/strong&gt; A continuous Node.js daemon polls Blink’s cloud, stages motion clips to NVMe, hands them to Anvil.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every clip lands on the same Anvil daemon, which runs one Gemma 4 E4B 4-bit MLX instance. The daemon serves two surfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/v1/analyze&lt;/code&gt; for Logbook (image-pass + native-video-pass per clip).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/v1/chat/completions&lt;/code&gt; and &lt;code&gt;/v1/responses&lt;/code&gt; for every other Forge VLM client in the homelab.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model does not care which surface called it. The previous standalone gemma-4-multimodal LaunchAgent was retired and its plist removed. End state: one Gemma 4 instance, dual-purpose, no duplication.&lt;/p&gt;

&lt;p&gt;Redaction happens once, at the ingest endpoint, before the INSERT. UUIDs, filesystem paths, IPv4 and IPv6, internal hostnames, email addresses, API key shapes. Single pass.&lt;/p&gt;




&lt;h2&gt;
  
  
  The day the model pretended to watch video
&lt;/h2&gt;

&lt;p&gt;For most of the build day, Logbook produced two summaries per clip: one from a native-video call &lt;code&gt;mlx_vlm.generate(video=path, fps=1.0)&lt;/code&gt;, and one from a separate frame-extracted multi-image pass.&lt;/p&gt;

&lt;p&gt;The image summaries were excellent. They read pixels at 1280 px width and reported real strings: Termius, Phase 9, LOGBOOK_BUILD_BRIEF.md. Per-capture variation. Forensic detail. Anyone reading the raw table rows could tell which IDE window was on top.&lt;/p&gt;

&lt;p&gt;The video summaries were a different story. Every video summary for every mac_screen capture, hour after hour, described “a person standing in a kitchen setting, facing a counter, holding a small dark object.” Word for word. The MacBook does not have a webcam pointed at the kitchen. The capture content was screen recordings.&lt;/p&gt;

&lt;p&gt;I revised the prompt to be explicit (“you are observing a screen recording from a computer display”). Every video summary then described an identical Stack Overflow visit. Still word-for-word across captures.&lt;/p&gt;

&lt;p&gt;The model was not hallucinating. Hallucinating implies seeing something and misinterpreting it. The model was outputting the same paragraph because the same paragraph was the most likely next-token sequence given only the prompt. The video bytes were not reaching the attention layer at all.&lt;/p&gt;

&lt;p&gt;An MD5-hash query broke the case open. Across seven consecutive mac_screen captures of five different windows, every video summary collapsed to two unique hashes (one per prompt variant), perfectly correlated with the prompt text. The image summaries from the same seven captures produced seven unique hashes. Image was reading pixels. Video was reading nothing.&lt;/p&gt;

&lt;p&gt;Running the same script against two different Blink motion clips from the living room made it worse. Identical output on E4B. Identical output on E2B. E2B’s variant of the bug was more honest than E4B’s: where E4B confabulated plausible scenes, E2B simply replied “Please provide the video or a description of what you are seeing so I can describe it for you.” &lt;strong&gt;The model was literally asking for the video.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Root cause was four lines deep in &lt;code&gt;anvil/server.py&lt;/code&gt;. The daemon was building the formatted prompt with &lt;code&gt;apply_chat_template(processor, config, prompt, num_images=N)&lt;/code&gt; and then calling &lt;code&gt;generate(video=path, ...)&lt;/code&gt;. &lt;br&gt;
The dispatcher in mlx_vlm’s &lt;code&gt;prompt_utils.py&lt;/code&gt; checks &lt;code&gt;kwargs.get("video")&lt;/code&gt; on the chat template call to decide whether to insert the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; placeholder. &lt;/p&gt;

&lt;p&gt;We were not passing it.&lt;/p&gt;

&lt;p&gt;The formatted prompt had no video marker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generate()&lt;/code&gt;’s &lt;code&gt;video=path&lt;/code&gt; argument was effectively ignored at the attention layer: the video tokens had no anchor in the prompt to attend to.&lt;/p&gt;

&lt;p&gt;The fix is one branch:&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;if&lt;/span&gt; &lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&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="n"&gt;video&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;num_images&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&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="n"&gt;num_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;num_images&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;After the fix, the same seven captures produced seven unique video summaries.&lt;/p&gt;

&lt;p&gt;The model was watching.&lt;/p&gt;

&lt;p&gt;The bug was masked by polite-looking output. The summaries were grammatical, plausible, well-formed paragraphs. They just had nothing to do with the input.&lt;/p&gt;




&lt;h2&gt;
  
  
  Numbers, and the redaction pass
&lt;/h2&gt;

&lt;p&gt;Isolated benchmarks on a single warmed clip, no other traffic on the daemon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Image pass: 4.08 s latency, 17.6 tok/s, 5.89 GB peak resident.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Video pass: 6.67 s latency, 14.1 tok/s, 6.03 GB peak resident.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Production averages across 467 ingested rows from a single day’s running, with the daemon also serving the rest of Forge’s VLM clients:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;avg image latency&lt;/th&gt;
&lt;th&gt;image tok/s&lt;/th&gt;
&lt;th&gt;avg video latency&lt;/th&gt;
&lt;th&gt;video tok/s&lt;/th&gt;
&lt;th&gt;peak resident&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mac_screen&lt;/td&gt;
&lt;td&gt;11.20 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;20.62 s&lt;/td&gt;
&lt;td&gt;33.9&lt;/td&gt;
&lt;td&gt;8.52 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;looki (clips)&lt;/td&gt;
&lt;td&gt;8.57 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;11.98 s&lt;/td&gt;
&lt;td&gt;33.9&lt;/td&gt;
&lt;td&gt;8.50 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blink&lt;/td&gt;
&lt;td&gt;24.85 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;27.31 s&lt;/td&gt;
&lt;td&gt;34.7&lt;/td&gt;
&lt;td&gt;8.52 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things shift between the bench and production.&lt;/p&gt;

&lt;p&gt;Throughput nearly doubles under load (33.7 tok/s vs. 17.6) because the model handles concurrent VLM work efficiently.&lt;/p&gt;

&lt;p&gt;Latency stretches by a factor of 2-6 depending on source because the same instance is now serving Logbook’s four producers alongside every other Forge VLM client.&lt;/p&gt;

&lt;p&gt;Peak resident memory climbs to 8.52 GB, still comfortably inside a 16 GB Mac mini.&lt;/p&gt;

&lt;p&gt;The latency stretch is the consolidation. One model, two surfaces, shared queue. Anvil idles at single-digit watts when the daemon is not actively inferring. Throughput is comfortable for the production cadence of all four sensors. No batching tricks required.&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%2F95uerw6t9k7junhty7wq.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%2F95uerw6t9k7junhty7wq.png" alt="Convergence" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The redaction pass is in production. A real row from this morning’s bronze layer, image summary verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Email visible: [REDACTED]. IP shown: [REDACTED]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model saw both. The Postgres row holds neither.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The model is local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The data is local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The redaction is at the ingest boundary.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The audit trail is a SELECT statement against a table on hardware I own.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What this actually changes
&lt;/h2&gt;

&lt;p&gt;The headline is not “I replaced OpenAI with Gemma.”&lt;/p&gt;

&lt;p&gt;The headline is that inference is no longer the bottleneck.&lt;/p&gt;

&lt;p&gt;When Chronicle does a screen capture, the inference is a network round trip to an API the user does not own, billed per request, rate-limited by the provider, and explicitly described in the provider’s own documentation as carrying “increased risk of prompt injection,” “memories stored as unencrypted Markdown files,” and consumption that “uses rate limits quickly.” The architecture treats each sensor as a customer of a paid service.&lt;/p&gt;

&lt;p&gt;When Logbook does a screen capture, the inference is a function call on hardware I own.&lt;/p&gt;

&lt;p&gt;The bottleneck is bytes-on-wire and bytes-on-disk, both of which are problems we already know how to solve.&lt;/p&gt;

&lt;p&gt;The model is a fixed cost.&lt;/p&gt;

&lt;p&gt;Every new sensor pays for itself in the wall clock of the moment it is added, not in the per-frame economics of the API.&lt;/p&gt;

&lt;p&gt;What ends up running on the Mac mini is closer to a personal telemetry fabric than to an AI assistant: distributed multi-modal sensors, normalized events, local inference, append-only memory.&lt;/p&gt;

&lt;p&gt;Chronicle did one thing competently and charged per frame.&lt;/p&gt;

&lt;p&gt;Logbook does the same thing four times over, from 360°, runs locally, and charges per electron.&lt;/p&gt;




&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;A Raspberry Pi Zero 2 W Basic was delivered to the house on May 16. A 250 g spool of 1.75 mm PLA filament arrived the day before.&lt;/p&gt;

&lt;p&gt;The shape of those two purchases together is a fifth sensor: a tiny always-on Linux SBC in a 3D-printed enclosure, somewhere on the spectrum of ambient sensor, audio recorder, or environmental probe.&lt;/p&gt;

&lt;p&gt;The exact function is the sensor’s business.&lt;/p&gt;

&lt;p&gt;The Logbook architecture does not care.&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%2Fe8i6dzznzy6uy6whf6fu.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%2Fe8i6dzznzy6uy6whf6fu.png" alt="Builder Desk" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fifth sensor will arrive at the same ingest endpoint, in the same envelope shape, summarized by the same Gemma 4 instance that is already running.&lt;/p&gt;

&lt;p&gt;Whatever it captures will slot into &lt;code&gt;raw_ingest_observations&lt;/code&gt; at its own &lt;code&gt;captured_at&lt;/code&gt; and interleave with the other four sources in time order.&lt;/p&gt;

&lt;p&gt;When it lands, the work will be writing one small handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is already there.
&lt;/h2&gt;

</description>
      <category>gemmachallenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>gemma</category>
    </item>
    <item>
      <title>I Wrote an MCP Server for My 3D Printer</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sun, 17 May 2026 20:30:55 +0000</pubDate>
      <link>https://dev.to/niclydon/i-wrote-an-mcp-server-for-my-3d-printer-4om3</link>
      <guid>https://dev.to/niclydon/i-wrote-an-mcp-server-for-my-3d-printer-4om3</guid>
      <description>&lt;p&gt;I’m writing this on a Sunday afternoon. The 3D printer on my kitchen counter has been printing for 19 hours and 12 minutes. I know this because I just asked it.&lt;/p&gt;

&lt;p&gt;Not by walking into the kitchen. By calling a tool in a Claude conversation:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;kiln_progress&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"printing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"looki_l1_tests.gcode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;257&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"progress"&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.9112598299980164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"print_duration_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;69192&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;The printer is a Flashforge Adventurer 5M. It’s named “kiln” because everything in my home lab gets a fire-themed name and I’ve already used the good ones (Furnace runs the GPUs, Forge is the inference gateway). It sits next to the cutting board. I bought it on a whim a few weeks ago and I have no idea what I’m doing with 3D printing as a hobby.&lt;/p&gt;

&lt;p&gt;But I do know how to wrap an API in MCP tools, and the printer has two of them. So now I have 16 MCP tools for a machine I barely understand.&lt;/p&gt;

&lt;p&gt;This post is the receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two APIs
&lt;/h2&gt;

&lt;p&gt;The AD5M ships with firmware that exposes two ways in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HTTP API on port 8898 that returns JSON for things like /detail (status, fans, temps, current job). This is what the FlashForge mobile app talks to.&lt;/li&gt;
&lt;li&gt;A legacy TCP port on 8899 that speaks G-code over a length-prefixed wire format. You send M115 and you get the firmware version back. Send M114 and you get the current head position.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HTTP API is comfortable. The TCP port is from a more savage era. Both are running on the same printer.&lt;/p&gt;

&lt;p&gt;kiln-mcp is a small TypeScript MCP server that wraps both. Read-only G-codes go through the TCP port. State-changing operations like kiln_print and kiln_control (pause/resume/cancel) go through the HTTP API. All calls carry a check code so the printer trusts them.&lt;/p&gt;

&lt;p&gt;I also do not trust any LLM with a hot nozzle and an open command channel.&lt;/p&gt;

&lt;p&gt;That shaped the design more than anything else. Read-only telemetry is permissive. Stateful operations are constrained. The kiln_mcode tool only accepts read-only M-codes because the first version of this server had that gate softer, and I tightened it after the second time I caught a tool call trying to send M104 (set extruder temp) inside what I thought was a status query.&lt;/p&gt;

&lt;p&gt;Here’s what they look like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read-only side
&lt;/h2&gt;

&lt;p&gt;kiln_info just dumps firmware and build volume. Under the hood it calls M115:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Machine Type: Flashforge Adventurer 5M
Machine Name: kiln
Firmware: v3.2.7
SN: [redacted]
X: 220 Y: 220 Z: 220
Tool Count: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kiln_temps is more useful. As of the moment I’m writing this paragraph:&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;"nozzle"&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;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;219.67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;220&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;"bed"&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;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;59.53&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;"chamber"&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;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;219.67 / 220 is the nozzle holding steady on PLA. 59.53 / 60 is the bed. The chamber slot exists for a heated enclosure I don’t have.&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%2Fkn0t471083ibh0ka85um.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%2Fkn0t471083ibh0ka85um.png" alt="Claude Mobile and 3D Printer" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;kiln_files lists what’s on the printer’s storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;looki_l1_tests.gcode
looki05.gcode
looki04.gcode
looki03.gcode
looki02.gcode
looki01.gcode
nameplate_batch_3_13-14_PLA_020mm.gcode
nameplate_batch_2_7-12_PLA_020mm.gcode
nameplate_batch_1x6_PLA_020mm.gcode
plate_1.gcode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’m iterating on a mount for a wearable AI camera I use, hence looki_l1_tests.gcode being on its fifth revision. The nameplate batches were for a friend. The file names are a journal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state-changing side
&lt;/h2&gt;

&lt;p&gt;kiln_print takes one of those file names and starts the job:&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;kiln_print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;file_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;looki05.gcode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kiln_control does pause / resume / stop. The stop is destructive in the obvious way: cancel an 18-hour print at hour 17, you have an 18-hour-old failed extrusion blob on the bed.&lt;/p&gt;

&lt;p&gt;I don’t let Claude call kiln_control casually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weird one
&lt;/h2&gt;

&lt;p&gt;The tool I actually built this server for is kiln_image2mesh.&lt;/p&gt;

&lt;p&gt;I wanted the shortest possible path from “that would make a neat print” to an STL on the printer.&lt;/p&gt;

&lt;p&gt;You hand it an image. It hands you back an STL ready to slice. It runs entirely on the iGPU of one of my mini PCs.&lt;/p&gt;

&lt;p&gt;Under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A FastAPI service called Modly that auto-spawns on first use (it isn’t running right now, which is fine).&lt;/li&gt;
&lt;li&gt;rembg strips the background from the image.&lt;/li&gt;
&lt;li&gt;TripoSG, an image-to-3D diffusion model from VAST AI, generates the mesh.&lt;/li&gt;
&lt;li&gt;A marching-cubes octree turns the implicit field into triangles.&lt;/li&gt;
&lt;li&gt;Mesh simplification brings the face count down to a printable target (default 80,000 faces).&lt;/li&gt;
&lt;li&gt;The result is written as an STL next to the input image.&lt;/li&gt;
&lt;/ul&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%2Fxnrmb64fsroyp23skyzj.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%2Fxnrmb64fsroyp23skyzj.png" alt="Kiln MCP" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Total time: 5 to 15 minutes depending on diffusion steps. CFG, seed, foreground ratio, face count, and steps are all parameters. Defaults are tuned for “preview-grade,” which is what I want 95% of the time.&lt;/p&gt;

&lt;p&gt;The point: there is no cloud STL service in this pipeline. The image goes onto disk on Furnace, Modly runs locally on the iGPU, the STL lands on the same disk, and then kiln_print ships it. The only thing leaving my network is the message I typed at Claude asking it to do all of that. (And sometimes I don't run that through Claude.)&lt;/p&gt;

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

&lt;p&gt;While drafting this, kiln_status timed out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timeout after 8000ms (path=/detail)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The printer’s HTTP server gets cranky when it’s deep into a long job. The legacy TCP port answered fine the whole time. Both APIs, one machine, very different attitudes about life. The MCP server papers over this poorly. That’s a TODO.&lt;/p&gt;

&lt;p&gt;kiln_modly_status reported api_up: false. The auto-spawn handles that on the next kiln_image2mesh call, but if I were writing this server today I’d add a –prewarm flag.&lt;/p&gt;

&lt;p&gt;The ugly old TCP side of the printer has actually been more reliable than the modern JSON API. Which feels about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;I write MCP servers for things at a much higher rate than is reasonable. Most of them are useful in the obvious “I can ask Claude about my pipeline state” way. A few are useful in the less obvious way of “I now have a sharp picture of what the underlying system actually exposes.”&lt;/p&gt;

&lt;p&gt;Wrapping the printer was the second category.&lt;/p&gt;

&lt;p&gt;The AD5M’s two APIs disagree about a lot of things: units, retry behavior, what “ready” means. Wrapping them forced me to pick a model. The MCP surface is the cleanest description of that printer I have.&lt;/p&gt;

&lt;p&gt;That’s the part of MCP work I think people miss. Once you expose a system through tools, you stop writing wrappers and start defining semantics. You have to decide what counts as state, what counts as safe, what operations deserve retries, and what an LLM should never be allowed to do.&lt;/p&gt;

&lt;p&gt;That, and: I can now ask Claude to print me a thing.&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%2Foy0focif5eprvs0it5jn.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%2Foy0focif5eprvs0it5jn.png" alt="Relaxed 3D Printing" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The amount of glue between an LLM and a hot extruder is not zero, but it’s smaller than you’d think.&lt;/p&gt;

&lt;p&gt;When I started writing this, the printer was at layer 257 of 315. I could check again.&lt;/p&gt;

&lt;p&gt;I’m not going to.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>Vibe Coding Is to Software Development as Desire Paths Are to City Planning</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sat, 16 May 2026 18:42:19 +0000</pubDate>
      <link>https://dev.to/niclydon/vibe-coding-is-to-software-development-as-desire-paths-are-to-city-planning-56d</link>
      <guid>https://dev.to/niclydon/vibe-coding-is-to-software-development-as-desire-paths-are-to-city-planning-56d</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm not a software developer. I'm the building inspector watching people pave their own paths through the enterprise. Here's what I'm seeing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In urban planning, there's a concept called a &lt;strong&gt;desire path&lt;/strong&gt;: the informal trail pedestrians wear into the grass when the sidewalk doesn't go where they actually need to go. It's not vandalism. It's feedback. The planned infrastructure failed to serve the people using it, and they routed around it.&lt;/p&gt;

&lt;p&gt;Vibe coding is the desire path of software development.&lt;/p&gt;

&lt;p&gt;But I'm not writing this to tell developers their profession is dying. I don't have standing for that. I'm a Director of Information Security. I manage security engineering and IAM teams. I've spent 15 years in cybersecurity and exactly zero of them shipping production applications.&lt;/p&gt;

&lt;p&gt;What I &lt;em&gt;do&lt;/em&gt; have is a front-row seat to what happens when the desire paths start forming inside an enterprise. And right now, they're everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Desire Paths Are Already There
&lt;/h2&gt;

&lt;p&gt;Here's what I'm seeing in my environment and hearing from peers:&lt;/p&gt;

&lt;p&gt;A financial analyst discovers they can use an AI coding assistant to build a Python script that automates a report they've been manually compiling every Monday for three years. It works. It runs on their laptop. Nobody in IT knows it exists.&lt;/p&gt;

&lt;p&gt;A compliance officer uses Claude to generate a small web app that tracks regulatory deadlines. It pulls from a shared spreadsheet. It sends Slack notifications. It took them an afternoon. The official request to IT for this tool has been in the backlog for 14 months.&lt;/p&gt;

&lt;p&gt;A project manager builds an internal dashboard by describing what they want to an LLM. It's not beautiful. It doesn't follow the design system. But it works, their team uses it, and it solved a problem that nobody else was going to solve for them.&lt;/p&gt;

&lt;p&gt;These are desire paths.&lt;/p&gt;

&lt;p&gt;And here's the uncomfortable truth: &lt;strong&gt;these people aren't wrong.&lt;/strong&gt; They needed something. The planned infrastructure — the IT backlog, the dev team's sprint priorities, the "submit a Jira ticket and wait" process — didn't serve them. So they walked across the grass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Leader's Problem
&lt;/h2&gt;

&lt;p&gt;As a security person, my instinct is obvious: this is terrifying. Ungoverned code running on laptops. API keys hardcoded in scripts. Data flowing to third-party AI services with no DLP, no audit trail, no access controls. Shadow IT, but now it's shadow &lt;em&gt;development&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fence-building response is also obvious: block the AI tools, lock down the endpoints, send a policy memo. The digital equivalent of &lt;strong&gt;KEEP OFF THE GRASS&lt;/strong&gt; signs.&lt;/p&gt;

&lt;p&gt;But I've been in security long enough to know that prohibition doesn't work when the underlying need is legitimate. You don't stop desire paths by putting up fences. You just make people walk through the mud next to the fence.&lt;/p&gt;

&lt;p&gt;The question isn't "how do I stop this?"&lt;/p&gt;

&lt;p&gt;The question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I pave these paths properly?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a Paved Desire Path Looks Like
&lt;/h2&gt;

&lt;p&gt;If citizen developers are going to build things — and they are, whether you like it or not — security and engineering teams need to build the infrastructure that makes it safe.&lt;/p&gt;

&lt;p&gt;Not safe as in "we reviewed every line of code."&lt;/p&gt;

&lt;p&gt;Safe as in "the paths have drainage, lighting, and load-bearing foundations."&lt;/p&gt;

&lt;p&gt;Here's the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The AI Gateway: Your Sidewalk
&lt;/h2&gt;

&lt;p&gt;Instead of letting every citizen developer hit OpenAI, Anthropic, or Google directly with their own API keys, you put a gateway in front of everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Citizen Developer → AI Gateway → [Local Models | Cloud Providers]
                        ↓
                   Audit Log
                   Policy Engine
                   Cost Controls
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my home lab, this is a service called Forge. Every AI request from every tool, agent, and script routes through it. In a 30-day window, that's 300K+ requests across 30+ models. Every single one is logged. Every cloud fallback is auditable at a dedicated endpoint.&lt;/p&gt;

&lt;p&gt;The numbers tell the story: $0.79 in actual cloud spend over 30 days, because the gateway routes to local models first and only falls back to cloud providers when necessary.&lt;/p&gt;

&lt;p&gt;But the cost savings aren't the point.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;auditability&lt;/strong&gt; is the point.&lt;/p&gt;

&lt;p&gt;When a regulator asks, "What data are your employees sending to AI services?", you need an answer.&lt;/p&gt;

&lt;p&gt;An enterprise version of this is an MCP proxy layer. MCP gives you a standardized interface between AI tools and the services they interact with. Put a proxy in front of it, and you control what every citizen-built tool can actually &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Guardrails: Your Drainage and Curbs
&lt;/h2&gt;

&lt;p&gt;A paved desire path still needs drainage so it doesn't flood. In the citizen developer context, guardrails are the constraints that prevent well-intentioned people from accidentally causing incidents.&lt;/p&gt;

&lt;p&gt;Concrete examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data classification enforcement.&lt;/strong&gt; The gateway inspects outbound requests. If someone's Python script is trying to send customer PII to a cloud model, the request gets blocked before it leaves the network. The citizen developer doesn't need to know about data classification policies. The infrastructure handles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential management.&lt;/strong&gt; No citizen developer should ever have a raw API key. The gateway handles authentication. The developer gets a single internal endpoint. If a key needs to be rotated, it happens once at the gateway, not in 47 scripts on 47 laptops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope limitation.&lt;/strong&gt; An MCP proxy can restrict which tools a citizen-built application can invoke. Your compliance officer's deadline tracker can read from the shared spreadsheet and send Slack notifications. It cannot access the HR system, modify financial records, or provision cloud resources. The path goes where it needs to go and nowhere else.&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="c1"&gt;# Example: MCP proxy policy for a citizen developer tool&lt;/span&gt;
&lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance-deadline-tracker&lt;/span&gt;
  &lt;span class="na"&gt;allowed_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;google_sheets:read&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;slack:post_message&lt;/span&gt;
  &lt;span class="na"&gt;blocked_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*:write"&lt;/span&gt;          &lt;span class="c1"&gt;# No writes to any data source&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*:delete"&lt;/span&gt;         &lt;span class="c1"&gt;# No deletions&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_system:*"&lt;/span&gt;      &lt;span class="c1"&gt;# No HR system access at all&lt;/span&gt;
  &lt;span class="na"&gt;data_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;block_pii_outbound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;max_tokens_per_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4096&lt;/span&gt;
  &lt;span class="na"&gt;audit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;log_all_requests&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;alert_on_block&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;h2&gt;
  
  
  3. The CI/CD Pipeline: Your Building Code
&lt;/h2&gt;

&lt;p&gt;This is where the city planning analogy lands hardest. A desire path that gets paved still has to meet building codes.&lt;/p&gt;

&lt;p&gt;For citizen developers, this means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A defined deployment path.&lt;/strong&gt; The tool doesn't run on someone's laptop forever. There's a simple process: push it to a repo, it goes through automated scanning — SAST, dependency checks, secrets detection — and it deploys to a managed environment. The citizen developer doesn't need to understand CI/CD. They need a button that says "make this official."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated security scanning.&lt;/strong&gt; Every citizen-built tool gets the same baseline checks that production code gets. Not a full security review — that doesn't scale — but automated detection of the things that cause most incidents: hardcoded secrets, known-vulnerable dependencies, SQL injection patterns, unvalidated inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment isolation.&lt;/strong&gt; Citizen developer tools run in sandboxed environments with limited network access, no production database credentials, and resource caps. If the tool breaks, it breaks in its sandbox. It doesn't take down the ERP system.&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%2F531ffgk9agjom9q5w2l0.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%2F531ffgk9agjom9q5w2l0.png" alt="Late Night Office" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Maintenance and Ownership: Your Public Works Department
&lt;/h2&gt;

&lt;p&gt;Here's the part every enterprise learns the hard way: paving the path is only the beginning.&lt;/p&gt;

&lt;p&gt;The compliance officer who built the regulatory tracker changes roles. The financial analyst who automated the Monday report leaves the company. Six months later, nobody knows who owns the tool, what depends on it, or whether it's still making correct decisions.&lt;/p&gt;

&lt;p&gt;This is where desire paths become technical debt corridors.&lt;/p&gt;

&lt;p&gt;A governed citizen development platform needs more than deployment pipelines and security scanning. It needs lifecycle management. Every deployed tool should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a recorded owner,&lt;/li&gt;
&lt;li&gt;a business purpose,&lt;/li&gt;
&lt;li&gt;dependency metadata,&lt;/li&gt;
&lt;li&gt;access scope documentation,&lt;/li&gt;
&lt;li&gt;and an expiration or review date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not because bureaucracy is fun, but because abandoned automation is one of the most dangerous forms of enterprise risk. A broken dashboard is visible. A silently incorrect dashboard can influence business decisions for months before anyone notices.&lt;/p&gt;

&lt;p&gt;That means periodic re-certification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the tool still need the access it was granted?&lt;/li&gt;
&lt;li&gt;Is anyone still using it?&lt;/li&gt;
&lt;li&gt;Are the underlying models or APIs behaving differently now?&lt;/li&gt;
&lt;li&gt;Has the source data changed format?&lt;/li&gt;
&lt;li&gt;Does the automation still align with current policy and process?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In city planning terms, this is the public works department. Roads crack. Drainage fails. Traffic patterns change. Some paths need widening because they became critical infrastructure. Others should be closed because the need disappeared.&lt;/p&gt;

&lt;p&gt;The same thing happens with citizen-built software. Some tools will prove valuable enough to formalize into fully supported applications. Others should expire automatically unless someone actively renews ownership and validates their continued use.&lt;/p&gt;

&lt;p&gt;If you don't build maintenance into the system from the beginning, today's paved path becomes tomorrow's forgotten infrastructure problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Skill Libraries: Your Signage and Lighting
&lt;/h2&gt;

&lt;p&gt;Smart cities don't just pave desire paths. They add lighting, signage, and benches. They make the path &lt;em&gt;better&lt;/em&gt; than the grass was.&lt;/p&gt;

&lt;p&gt;For citizen developers, this means pre-built, vetted capabilities they can use instead of building from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-approved integrations:&lt;/strong&gt; vetted connectors to internal systems, such as read-only Salesforce access, Slack posting, or Jira ticket creation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template repositories:&lt;/strong&gt; starter projects with security best practices already baked in: environment variable management, logging, error handling, input validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curated model access:&lt;/strong&gt; purpose-specific model configurations for summarization, data extraction, code generation, and other common patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Role That Emerges
&lt;/h2&gt;

&lt;p&gt;Here's the part software developers should actually pay attention to.&lt;/p&gt;

&lt;p&gt;The city planners didn't disappear when cities started paving desire paths. The profession matured. The job shifted from "design where people should walk" to "design systems that accommodate where people &lt;em&gt;do&lt;/em&gt; walk."&lt;/p&gt;

&lt;p&gt;That's what's happening in software development.&lt;/p&gt;

&lt;p&gt;The highest-leverage work isn't writing the compliance deadline tracker. It's building the platform that lets the compliance officer build it safely.&lt;/p&gt;

&lt;p&gt;It's the gateway, the proxy layer, the policy engine, the scanning pipeline, the sandboxed runtime, the skill libraries, and the lifecycle controls.&lt;/p&gt;

&lt;p&gt;The enduring engineering advantage shifts upward into platforms, governance, orchestration, and operational architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Actually Doing About It
&lt;/h2&gt;

&lt;p&gt;I'm not writing this from theory. I'm the security leader who has to make a decision: fence or sidewalk?&lt;/p&gt;

&lt;p&gt;Here's my approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge the desire paths exist.&lt;/strong&gt; The shadow AI tools are already in your environment. Pretending otherwise is negligence, not strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument before you govern.&lt;/strong&gt; Before writing policies, understand what's actually happening. Where are the API calls going? What data is flowing? What tools are people building?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the governed path.&lt;/strong&gt; Stand up the gateway, the proxy layer, the scanning pipeline. Make the official path easier than the unofficial one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the right thing the easy thing.&lt;/strong&gt; Every security control that adds friction to the citizen developer's workflow is a control they'll route around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit continuously, review periodically.&lt;/strong&gt; Automated scanning catches the baseline. Periodic human review catches the architectural issues. Neither alone is sufficient.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Conclusion
&lt;/h2&gt;

&lt;p&gt;The software development industry spent decades building beautiful, winding paths through meticulously planned courtyards: frameworks, design patterns, architectural review boards, sprint ceremonies, code review processes.&lt;/p&gt;

&lt;p&gt;Then someone handed everyone an AI assistant and they cut straight across the grass.&lt;/p&gt;

&lt;p&gt;That's not a failure of the people walking.&lt;/p&gt;

&lt;p&gt;That's feedback about the path.&lt;/p&gt;

&lt;p&gt;The question for security leaders isn't whether to allow it. It's already happening. The question is whether you're the one who paves the path with proper drainage, or the one standing next to a &lt;strong&gt;KEEP OFF THE GRASS&lt;/strong&gt; sign watching everyone walk through the mud.&lt;/p&gt;

&lt;p&gt;I know which one I'm choosing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Put Gemma 4 Behind My Homelab AI Gateway. This Is the Beginning.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Wed, 13 May 2026 02:22:11 +0000</pubDate>
      <link>https://dev.to/niclydon/i-put-gemma-4-behind-my-homelab-ai-gateway-this-is-the-beginning-487m</link>
      <guid>https://dev.to/niclydon/i-put-gemma-4-behind-my-homelab-ai-gateway-this-is-the-beginning-487m</guid>
      <description>

&lt;p&gt;Most model experiments start with a notebook, a benchmark script, or a quick API call.&lt;/p&gt;

&lt;p&gt;This one started with a production-shaped question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can I swap out an entire model family that is currently serviing the default paths through my actual local AI gateway?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a side demo. Not a one-off curl. Not "look, it runs."&lt;/p&gt;

&lt;p&gt;I mean the real route: the gateway that agents, background jobs, app surfaces, benchmark harnesses, and my own tools already call.&lt;/p&gt;

&lt;p&gt;That is the experiment I started with Gemma 4.&lt;/p&gt;

&lt;p&gt;This post is the beginning of that story, not the final verdict. I am writing it while the platform is still in the trial window. The follow-up will be more interesting: what stayed stable, what broke under real load, what got rolled back, and what I would keep after a week or two of actual use.&lt;/p&gt;

&lt;p&gt;For now, this is the setup: what I changed, why I changed it, and what failed immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Platform Before The Swap
&lt;/h2&gt;

&lt;p&gt;My local AI stack is built around a gateway I call Forge.&lt;/p&gt;

&lt;p&gt;Forge gives callers one OpenAI-ish API surface and handles the messy parts behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which model should answer this kind of request&lt;/li&gt;
&lt;li&gt;which machine is hosting it&lt;/li&gt;
&lt;li&gt;whether the model is hot, cold, deprecated, or on-demand&lt;/li&gt;
&lt;li&gt;whether a request is chat, vision, embedding, transcription, code, extraction, or something else&lt;/li&gt;
&lt;li&gt;whether a backend is available or should be skipped&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The machines behind it are consumer hardware, not datacenter gear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Furnace&lt;/td&gt;
&lt;td&gt;Primary inference box, AMD Strix Halo, 96 GB unified VRAM allocated to the iGPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crucible&lt;/td&gt;
&lt;td&gt;Secondary AMD box for creative workloads, permissive models, and burst/bulk work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anvil&lt;/td&gt;
&lt;td&gt;M4 Mac mini, useful for MLX/Metal paths and lightweight resident services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before this experiment, the default local text path was mostly Qwen-family. That was not an accident. Qwen had become the operating baseline because it was predictable enough for a platform, not just impressive in isolation.&lt;/p&gt;

&lt;p&gt;I had also tested other models. Devstral2, for example, was interesting enough to onboard and benchmark seriously. The smaller 24B variant was competitive in code scenarios, but it did not become the default path. The 123B model was too slow for the role I needed. That distinction matters:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model can be good and still not be a good platform default.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the bar Gemma 4 had to clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Did An In-Place Swap
&lt;/h2&gt;

&lt;p&gt;I could have added Gemma 4 as another optional model and called it a day.&lt;/p&gt;

&lt;p&gt;That would have been safer. It also would have taught me much less.&lt;/p&gt;

&lt;p&gt;Instead, I treated it like a real migration. For the trial window, Gemma 4 took over the canonical roles that real callers already use.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Previous route&lt;/th&gt;
&lt;th&gt;Trial route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default chat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3.6-chat-35b-a3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-chat-31b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;priority chat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-chat-26b-a4b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vision / multimodal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-vl-30b-a3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-multimodal-8b-e4b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt enhancement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-4b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-multimodal-2b-e2b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The old Qwen routes were not deleted. They were marked deprecated with a planned rollback window. That gives me a clean flip-back path if the experiment does not earn its keep.&lt;/p&gt;

&lt;p&gt;This is the part I think model posts often skip. A real model migration is not just "can I run it?" It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;do I have the right weights on disk?&lt;/li&gt;
&lt;li&gt;does my serving stack understand the architecture?&lt;/li&gt;
&lt;li&gt;can I fit the hot set in memory?&lt;/li&gt;
&lt;li&gt;do my existing aliases and callers still work?&lt;/li&gt;
&lt;li&gt;can I roll back without spelunking through five repos?&lt;/li&gt;
&lt;li&gt;do I have telemetry that will tell me the difference between model failure, gateway failure, and benchmark nonsense?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Failure Was Not The Model
&lt;/h2&gt;

&lt;p&gt;The first deploy crashed.&lt;/p&gt;

&lt;p&gt;Forge restarted cleanly. The model catalog showed the new Gemma 4 ids. The first smoke request hit the gateway, routed to llama-swap, and came back as a 502.&lt;/p&gt;

&lt;p&gt;The useful error was one layer lower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown model architecture: 'gemma4'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was not Gemma 4 quality. The problem was my serving binary.&lt;/p&gt;

&lt;p&gt;My llama.cpp build was from April 1. It was 466 commits behind the branch I needed. The GGUF files declared &lt;code&gt;general.architecture: gemma4&lt;/code&gt;, and the old build simply did not know what that meant.&lt;/p&gt;

&lt;p&gt;So the first chapter of the Gemma 4 experiment was not prompting. It was infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;back up the existing build tree&lt;/li&gt;
&lt;li&gt;rebuild llama.cpp with ROCm/HIP for Strix Halo&lt;/li&gt;
&lt;li&gt;verify the new binary recognizes the Gemma 4 architecture&lt;/li&gt;
&lt;li&gt;regression-check the existing Qwen route&lt;/li&gt;
&lt;li&gt;restart the serving layer&lt;/li&gt;
&lt;li&gt;smoke test through the actual gateway, not a side process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after that did the model start answering.&lt;/p&gt;

&lt;p&gt;That is a useful reminder: "model support" is not a binary property. A model can be downloadable, quantized, and present on disk, and still be unusable because the serving stack is one architecture handler behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Failure Was More Interesting
&lt;/h2&gt;

&lt;p&gt;Once Gemma 4 loaded, the first real chat benchmark looked bad.&lt;/p&gt;

&lt;p&gt;Not "a little worse than Qwen" bad. Broken bad.&lt;/p&gt;

&lt;p&gt;On the initial chat-bench run, &lt;code&gt;gemma-4-chat-31b&lt;/code&gt; failed the structured extraction and format-compliance scenarios. It was also slow enough that something was clearly wrong. These were not hard prompts. These were the boring, throughput-oriented tasks that agents and background workers need to complete cleanly.&lt;/p&gt;

&lt;p&gt;A direct request showed the issue immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;think&amp;gt;
The user is asking a basic arithmetic question...
&amp;lt;/think&amp;gt;

2 + 2 = 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model was spending the answer budget on a reasoning block.&lt;/p&gt;

&lt;p&gt;For a human chat UI, visible thinking might be useful. For a benchmark expecting JSON, or an agent expecting a short answer, it is poison. The model can know the right answer and still fail the task because the caller never receives the shape it asked for.&lt;/p&gt;

&lt;p&gt;This was familiar. Forge had already solved the same class of problem for Qwen3.&lt;/p&gt;

&lt;p&gt;The fix was to make "thinking mode" a gateway policy, not a model identity.&lt;/p&gt;

&lt;p&gt;Programmatic callers get:&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;"chat_template_kwargs"&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;"enable_thinking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;injected by default when the model family needs it.&lt;/p&gt;

&lt;p&gt;Chat UIs can opt back in explicitly:&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;"chat_template_kwargs"&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;"enable_thinking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;That is the right abstraction for my platform. I do not want every agent, benchmark, worker, and internal tool to remember which local model family wraps output in thinking tokens this week. I want the gateway to know that once.&lt;/p&gt;

&lt;p&gt;After that change, the chat benchmarks became meaningful. The three relevant routes - Gemma 4 31B, Gemma 4 26B-A4B, and the displaced Qwen3.6 35B-A3B baseline - reached the same pass-rate shape across the default chat scenarios.&lt;/p&gt;

&lt;p&gt;The interesting result was latency. The 26B-A4B route was materially faster than both the dense 31B and the Qwen3.6 baseline on several workloads, while keeping the same pass rate in the corrected harness.&lt;/p&gt;

&lt;p&gt;That is the kind of result I care about. Not "model X wins," but "model X belongs in this role."&lt;/p&gt;

&lt;h2&gt;
  
  
  Vision Exposed A Different Problem
&lt;/h2&gt;

&lt;p&gt;The multimodal side taught a separate lesson.&lt;/p&gt;

&lt;p&gt;I added a new VLM benchmark harness and ran the obvious first test. The initial scenario was too weak. It was good enough as a smoke test, but not good enough to tell me which model or host was better.&lt;/p&gt;

&lt;p&gt;So I built a more discriminating scenario with three generated fixtures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a bar chart that required OCR and chart reasoning&lt;/li&gt;
&lt;li&gt;a code screenshot that required reading a function name and language&lt;/li&gt;
&lt;li&gt;a homelab topology diagram that required identifying the hub and connected nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then another problem appeared: concurrency.&lt;/p&gt;

&lt;p&gt;Promptfoo's default concurrency sent multiple image-bearing requests at once during a backend startup window, which produced misleading 502s. Some errors appeared to implicate the wrong backend because parallel requests were failing around the same time.&lt;/p&gt;

&lt;p&gt;Sequential runs told the truth.&lt;/p&gt;

&lt;p&gt;With concurrency set to 1 and the output budget raised, the final VLM run passed cleanly across the tested local routes. The surprising part was not that Gemma 4 could read the images. The surprising part was that an M4 Mac mini running an MLX path was effectively tied with the AMD inference box on this small, practical vision benchmark.&lt;/p&gt;

&lt;p&gt;That is not a leaderboard result. It is a routing result.&lt;/p&gt;

&lt;p&gt;It tells me Anvil is not just a dev box. For some multimodal work, it is a useful inference target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio Needed A Sidecar
&lt;/h2&gt;

&lt;p&gt;Gemma 4's multimodal story is not just text and images. Audio is part of the interesting surface.&lt;/p&gt;

&lt;p&gt;But my normal GGUF plus llama.cpp path did not support Gemma 4 audio input yet. The text and vision path worked through llama-swap. The audio conformer path did not.&lt;/p&gt;

&lt;p&gt;So I built it as a sidecar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a separate FastAPI worker&lt;/li&gt;
&lt;li&gt;safetensors weights&lt;/li&gt;
&lt;li&gt;HuggingFace Transformers&lt;/li&gt;
&lt;li&gt;ROCm-specific PyTorch wheels&lt;/li&gt;
&lt;li&gt;a Forge route at &lt;code&gt;/v1/audio_qa&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That route is intentionally not a replacement for Whisper.&lt;/p&gt;

&lt;p&gt;Whisper remains the right tool for long-form transcription. Gemma 4 audio is more interesting for short audio understanding, audio Q&amp;amp;A, emotion or intent questions, and cross-modal prompts that combine audio and an image.&lt;/p&gt;

&lt;p&gt;The first useful test was simple: the JFK sample clip. The route returned a good short transcription in under six seconds once warm. A 60 second clip correctly failed fast with a 413 because the audio path is capped at 30 seconds. An audio plus image prompt produced a coherent response grounded in both modalities.&lt;/p&gt;

&lt;p&gt;That sidecar is not the end state. It is a bridge. When the standard serving path supports the audio input cleanly, the route can stay and the backend can change.&lt;/p&gt;

&lt;p&gt;Again, that is the platform lesson: callers should not care which inference backend made the modality work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Am Actually Testing
&lt;/h2&gt;

&lt;p&gt;The easy version of this post would end with a benchmark table and a confident take.&lt;/p&gt;

&lt;p&gt;I do not have that yet, and pretending otherwise would be silly.&lt;/p&gt;

&lt;p&gt;What I have is the beginning of a platform trial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemma 4 is now in the main chat, priority-chat, multimodal, and prompt-enhance roles.&lt;/li&gt;
&lt;li&gt;Qwen is still available as a rollback path.&lt;/li&gt;
&lt;li&gt;Devstral2 remains useful but not a default for this platform.&lt;/li&gt;
&lt;li&gt;Forge now handles thinking-mode policy for both Qwen3 and Gemma 4.&lt;/li&gt;
&lt;li&gt;The benchmark harness is better than it was before the experiment.&lt;/li&gt;
&lt;li&gt;The audio path exists, but it is a sidecar until the normal serving stack catches up.&lt;/li&gt;
&lt;li&gt;The real evaluation is now happening under actual workloads.&lt;/li&gt;
&lt;/ul&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%2F4zgtw0skqr40dpsmdtgz.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%2F4zgtw0skqr40dpsmdtgz.png" alt="LLM Benchmark" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question I care about over the next week is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is Gemma 4 better than Qwen?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which parts of the platform are better with Gemma 4 in the route, and which parts should move back?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means watching boring things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;error rates&lt;/li&gt;
&lt;li&gt;429s and backend saturation&lt;/li&gt;
&lt;li&gt;latency under background jobs&lt;/li&gt;
&lt;li&gt;whether agent outputs stay clean&lt;/li&gt;
&lt;li&gt;whether structured tasks remain reliable&lt;/li&gt;
&lt;li&gt;whether multimodal routes are useful often enough to stay hot&lt;/li&gt;
&lt;li&gt;whether the memory footprint is worth it&lt;/li&gt;
&lt;li&gt;whether fallback behavior is predictable when the boxes are busy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is less glamorous than a benchmark screenshot. It is also where the real answer lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway So Far
&lt;/h2&gt;

&lt;p&gt;The first day did not teach me that Gemma 4 is universally better. It taught me something more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model family becomes valuable when the platform can route it intentionally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The gateway mattered more than any single model call.&lt;/p&gt;

&lt;p&gt;Without Forge, I would have been debugging each app and agent separately. With Forge, the migration became a small number of role changes, a serving-stack rebuild, one generalized thinking-policy fix, and a better set of benchmarks.&lt;/p&gt;

&lt;p&gt;That is the part I want to keep building toward: a local AI platform where model families can change without rewriting every caller, and where the system learns from real workloads instead of one-off demos.&lt;/p&gt;

&lt;p&gt;This is the start of the Gemma 4 trial in my homelab.&lt;/p&gt;

&lt;p&gt;In a week or two, I should have the more honest post: what survived real use, what got demoted, and what I would do differently if I were starting the swap again.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Audited My AI Agents and Found That Most of Their Reasoning Wasn’t Observable</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 12 May 2026 01:01:24 +0000</pubDate>
      <link>https://dev.to/niclydon/i-audited-my-ai-agents-and-found-that-most-of-their-reasoning-wasnt-observable-4a5</link>
      <guid>https://dev.to/niclydon/i-audited-my-ai-agents-and-found-that-most-of-their-reasoning-wasnt-observable-4a5</guid>
      <description>&lt;p&gt;I run a personal AI platform with eight active agents, dozens of processors, and a fully self-hosted Langfuse instance. I built the observability layer myself. I shipped it a few weeks ago. Last week I ran the audit query for the first time.&lt;/p&gt;

&lt;p&gt;The agents that talk to me the most only had Langfuse-level lineage coverage for about 13% of their decisions.&lt;/p&gt;

&lt;p&gt;This is the writeup of what I found, why it happened, and the schema and code that explain it. If you run agents and you've never run this audit, you have a very good chance of finding the same gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Quick context. The platform is called Nexus. It's a TypeScript monorepo plus a fleet of Python processors, running on a couple of mini PCs in my apartment. It ingests 26 data sources, runs 8 reasoning agents on schedules, and serves an MCP tool surface I use as my daily driver.&lt;/p&gt;

&lt;p&gt;Two layers matter for this post:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agents&lt;/strong&gt; are reasoning entities. They read from gold-layer tables, decide things, and write proposals to inbox tables. ARIA is the user-facing coordinator. Chronicler owns the timeline. Insight does anomaly detection. Five others fill in around them. They're scheduled, bounded, and they don't directly execute infrastructure changes — they propose, a human decides.&lt;/p&gt;

&lt;p&gt;Every agent decision lands in a row in &lt;code&gt;agent_decisions&lt;/code&gt;. Every row has a &lt;code&gt;trace_id&lt;/code&gt; like &lt;code&gt;aria-1777559470433-5c0db36c&lt;/code&gt;. That trace_id is generated by the agent itself at the start of a cycle and is 100% covered. It tells you the agent ran. It does not tell you what the LLM was asked or what it returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The processors&lt;/strong&gt; are the deterministic side. They read raw data, enrich it, write to silver and gold. Some call LLMs (Gmail enrichment, ambient capture upgrade, financial event extraction). Each run lands in &lt;code&gt;aurora_processing_runs&lt;/code&gt; with a &lt;code&gt;langfuse_trace_id&lt;/code&gt; column populated when the run had Langfuse turned on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Langfuse itself&lt;/strong&gt; is self-hosted on a host on my private network. It's been running fine for weeks. It has traces in it. The dashboard shows traces. I have used the dashboard.&lt;/p&gt;

&lt;p&gt;I just hadn't asked the question "what fraction of my agent and processor activity is actually represented there."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Audit Query
&lt;/h2&gt;

&lt;p&gt;The MCP tool that surfaced this is &lt;code&gt;nexus_agent_architecture_status&lt;/code&gt;. Under the hood it's running this against the operational Nexus Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cycle'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&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="nb"&gt;int&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;
         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;state_snapshot&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'langfuse_enabled'&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_langfuse_flag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;state_snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'langfuse_enabled'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;langfuse_enabled_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state_snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'langfuse_trace_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_langfuse_trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_decision_at&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agent_decisions&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&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="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cycle'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invocation_type&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;code&gt;state_snapshot&lt;/code&gt; column is JSONB. Every agent cycle writes a small snapshot of the runtime config it ran under, including whether Langfuse was enabled, the active trace ID, and (when disabled) a &lt;code&gt;langfuse_disabled_reason&lt;/code&gt; string. This is the schema that lets me tell the difference between "we never tried to trace" and "we tried and failed."&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%2Fha91jd4laxpul5i0p0hg.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%2Fha91jd4laxpul5i0p0hg.png" alt="Audit Query" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The result over a 30-day window, sorted by decision volume:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Decisions&lt;/th&gt;
&lt;th&gt;Internal trace&lt;/th&gt;
&lt;th&gt;Langfuse trace&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Executor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ARIA&lt;/td&gt;
&lt;td&gt;31,451&lt;/td&gt;
&lt;td&gt;31,451&lt;/td&gt;
&lt;td&gt;5,452&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insight&lt;/td&gt;
&lt;td&gt;25,913&lt;/td&gt;
&lt;td&gt;25,913&lt;/td&gt;
&lt;td&gt;4,402&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chronicler&lt;/td&gt;
&lt;td&gt;23,297&lt;/td&gt;
&lt;td&gt;23,297&lt;/td&gt;
&lt;td&gt;2,950&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circle&lt;/td&gt;
&lt;td&gt;21,510&lt;/td&gt;
&lt;td&gt;21,510&lt;/td&gt;
&lt;td&gt;2,490&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infra&lt;/td&gt;
&lt;td&gt;19,701&lt;/td&gt;
&lt;td&gt;19,701&lt;/td&gt;
&lt;td&gt;2,524&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correlator&lt;/td&gt;
&lt;td&gt;2,594&lt;/td&gt;
&lt;td&gt;2,594&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;2,591&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keeper&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table in two passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First pass:&lt;/strong&gt; the agents producing the most decisions (ARIA at 31K, Insight at 25K) are the ones with the lowest Langfuse coverage (12–17%). The agents with low volume (Correlator, Planner, Keeper) sit at 100%. Inversely correlated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second pass:&lt;/strong&gt; it's not actually about volume. It's about something the volume happens to correlate with. The five high-volume agents are the ones whose execution is shaped by an older code path; the three high-coverage agents are on the newer one. Keeper runs on a different executor entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's in the Untraced Rows
&lt;/h2&gt;

&lt;p&gt;Pulling a sample of the rows where &lt;code&gt;langfuse_enabled&lt;/code&gt; is false tells the story directly:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;141946&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aria"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invocation_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cycle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aria-1777559470433-5c0db36c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-30T14:31:17.266Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"langfuse_disabled_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LANGFUSE_ENABLED is false"&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 field is the answer. At the moment of that decision, the agent process saw &lt;code&gt;LANGFUSE_ENABLED=false&lt;/code&gt; in its environment and routed every LLM call through the no-op path.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the No-Op Path Works
&lt;/h2&gt;

&lt;p&gt;Here's the actual gating code, lightly trimmed, from &lt;code&gt;packages/core/src/services/langfuse-client.ts&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLangfuseConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;LangfuseConfig&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="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;parseBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_ENABLED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// default false&lt;/span&gt;
    &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;trimTrailingSlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="c1"&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;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runWithLangfuseTrace&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LangfuseTraceParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fn&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;LangfuseTraceContext&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;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&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;T&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;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLangfuseConfig&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;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDisabledReason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&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;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;warnDisabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// logs once per process&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// run the work, no trace&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... normal trace path&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 a textbook pattern. Default off. Fail open. Log once. Never block the agent.&lt;/p&gt;

&lt;p&gt;The pattern is right. It's the same one the Python services use, and the same one the publishing pipeline uses for its drafting code. You don't want a Langfuse outage taking down agents.&lt;/p&gt;

&lt;p&gt;What the pattern doesn't do is tell you when it's been firing for weeks.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;warnDisabled&lt;/code&gt; call is guarded by a module-level boolean so it only logs once per process lifetime. The next 10,000 calls to &lt;code&gt;runWithLangfuseTrace&lt;/code&gt; from that process are silent. No counter, no metric, no row in the disabled-runs table. Just a single line in stdout that scrolled past at startup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Story: It Was Never Turned On
&lt;/h2&gt;

&lt;p&gt;I went looking through every checked-in config file for &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt;:&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="nv"&gt;$ &lt;/span&gt;rg &lt;span class="s2"&gt;"LANGFUSE_ENABLED"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yaml &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits. The flag isn't set in any committed config. The agents that have full Langfuse coverage are the ones whose runtime environment happens to have &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt; set somewhere out of band — a systemd unit, an inherited shell env, a compose override that lives on the host.&lt;/p&gt;

&lt;p&gt;That explains the table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeper&lt;/strong&gt; runs under the newer executor process, which inherits an env that has the flag set. 100% coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlator and Planner&lt;/strong&gt; are recent additions wired into a different runtime path that always emits Langfuse spans regardless of the flag. 100% coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The five high-volume agents&lt;/strong&gt; (ARIA, Insight, Chronicler, Circle, Infra) run under the older executor. Most of the time it doesn't see the flag. Occasionally it does — about 12-17% of cycles — probably the ones that happen to fall after a manual restart in a shell where the flag was exported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not drift. It's never having been turned on in the first place for the path that does the most work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Processor Side Has the Same Shape
&lt;/h2&gt;

&lt;p&gt;Pulling the 30 most recent rows from &lt;code&gt;aurora_processing_runs&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Processor Name&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Has Trace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ambient-moment-sync&lt;/td&gt;
&lt;td&gt;2026-04-29.langfuse-v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gmail-enrich&lt;/td&gt;
&lt;td&gt;2026-04-29.events-v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gmail-appointment-extract&lt;/td&gt;
&lt;td&gt;2026-04-30.v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mem-bronze-drain&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plans-to-kg&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;voice-to-kg&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;social-bronze-drain&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ambient-context-upgrade-processor&lt;/td&gt;
&lt;td&gt;2026-04-29.context-v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;health-timeline-promote&lt;/td&gt;
&lt;td&gt;2026-04-30.v2&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same pattern. Processors with a &lt;code&gt;langfuse-v1&lt;/code&gt; or &lt;code&gt;events-v1&lt;/code&gt; tag in the version string emit trace IDs because their code was explicitly migrated to call &lt;code&gt;runWithLangfuseTrace&lt;/code&gt;. Processors still on &lt;code&gt;v1&lt;/code&gt; were written before the migration helper existed and never adopted it. They call &lt;code&gt;traceLlmGeneration&lt;/code&gt; if they make LLM calls, but the outer trace context is missing, so the spans don't correlate to anything queryable.&lt;/p&gt;

&lt;p&gt;The version string is doing the work the env flag isn't. It encodes whether the code knows about the tracing helper.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Generalizes
&lt;/h2&gt;

&lt;p&gt;I run this stack as one person. Eight agents, a handful of processors, one Langfuse instance, one set of credentials. The fix is a long afternoon. The same problem at any non-trivial agent deployment is much more expensive to discover and much more expensive to close, because by the time you ask the question you have hundreds of thousands of decisions you can't reconstruct.&lt;/p&gt;

&lt;p&gt;Three patterns that generalize from this audit:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Decision counts are not coverage.
&lt;/h3&gt;

&lt;p&gt;Every dashboard I had was counting decisions and showing them as green. None of them computed coverage ratios. Decision counts tell you the agent ran. They don't tell you whether you can answer what it did. If you're going to instrument observability, instrument the observability itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Default-off is correct. Silent default-off is not.
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;parseBool(env.LANGFUSE_ENABLED, false)&lt;/code&gt; default is right. You don't want observability code that fails closed and breaks the agent. But there's a difference between "fails open" and "fails open silently for weeks." The fix is a periodic check, on a separate cadence from the agents themselves, that reports &lt;code&gt;langfuse_enabled=false&lt;/code&gt; across {n} cycles in the last hour to a channel a human will see. The disabled-reason field already exists. Aggregating it is one cron job.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Code-version is the actual observability gate.
&lt;/h3&gt;

&lt;p&gt;The flag check is a red herring. The real question is whether the agent or processor was written to call into the tracing helper at all. &lt;code&gt;2026-04-29.langfuse-v1&lt;/code&gt; in a version string is a much better predictor of coverage than the env flag. Treat your tracing migration as a code migration, audit by version, and don't assume an env flag covers the gap.&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%2Fe02ucc9bb22u84fu0mx0.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%2Fe02ucc9bb22u84fu0mx0.png" alt="Generalizations" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Doing About It
&lt;/h2&gt;

&lt;p&gt;Three things, in this order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the flag where it should always have been set.&lt;/strong&gt; This is the embarrassing one. Add &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt; to the older executor's systemd unit, restart, verify with one cycle from each of the five low-coverage agents. This closes the going-forward gap immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Materialize coverage as a first-class metric.&lt;/strong&gt; A view, &lt;code&gt;agent_observability_coverage&lt;/code&gt;, computed from the audit query above on a rolling 24-hour window. A small alert that fires if any active agent drops below 95%. The view is gitignored config; the alert lives in the existing notification path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backfill triage.&lt;/strong&gt; I can't recover the prompts and responses for the 100,000+ untraced decisions. They're gone. What I can do is replay the inputs for the high-importance subset — anything that touched a person record, anything in the financial event flow, anything routed through ARIA's user-facing path — and emit a post-hoc trace with whatever the prompt would have been at the version pin recorded in &lt;code&gt;state_snapshot.prompt_version&lt;/code&gt;. The output won't match what actually happened. But it gives a baseline for behavioral drift detection going forward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The Nexus doctrine line is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nexus is best understood as a data and memory platform with bounded reasoning agents on top, not as an unbounded autonomous swarm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The corollary I hadn't written down until now is that bounded reasoning is only bounded if you can see the reasoning. A &lt;code&gt;trace_id&lt;/code&gt; that points to a row with no LLM-level lineage isn't bounded reasoning. It's bounded execution with hidden reasoning behind it.&lt;/p&gt;

&lt;p&gt;The agents I was most worried about turned out to be the ones I was least able to inspect. That's the inverse of the order I would have chosen.&lt;/p&gt;

&lt;p&gt;The fix is straightforward. The lesson is that I had to write a query to find out.&lt;/p&gt;




&lt;p&gt;The public architectural repository for Nexus is available here: &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;github.com/niclydon/nexus-public&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One important clarification: nexus-public intentionally does not ship with hard dependencies on vendor-specific observability and evaluation tooling like Langfuse, Promptfoo, and several other operational integrations I use in the live runtime. The public repo is designed more as an architectural reference implementation — agents, processors, MCP tooling, schemas, orchestration boundaries, and execution patterns — so someone can wire in whichever tracing and observability stack they prefer rather than inheriting mine by default.&lt;/p&gt;

&lt;p&gt;The Langfuse integration, executor runtime paths, and audit tooling discussed in this post come from the private operational implementation that powers the platform day to day.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>typescript</category>
      <category>devops</category>
    </item>
    <item>
      <title>It’s Not Just the College Kids</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sun, 10 May 2026 20:01:15 +0000</pubDate>
      <link>https://dev.to/niclydon/its-not-just-the-college-kids-57ha</link>
      <guid>https://dev.to/niclydon/its-not-just-the-college-kids-57ha</guid>
      <description>&lt;p&gt;Sam Altman told a Sequoia Capital audience that older people use ChatGPT like Google, millennials use it as a life advisor, and college students use it as an operating system.&lt;/p&gt;

&lt;p&gt;He’s not wrong about the college students. He’s wrong about who else is doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data doesn’t support the generational frame
&lt;/h2&gt;

&lt;p&gt;Altman’s taxonomy is intuitive. Younger people grew up with these tools. Of course they’d go deeper.&lt;/p&gt;

&lt;p&gt;But the &lt;a href="https://survey.stackoverflow.co/2025/" rel="noopener noreferrer"&gt;Stack Overflow 2025 Developer Survey&lt;/a&gt; tells a different story. Developers with 10-19 years of experience were among the heaviest AI adopters, consistently reported strong productivity gains, and were simultaneously the &lt;em&gt;least likely&lt;/em&gt; cohort to highly trust AI output.&lt;/p&gt;

&lt;p&gt;That combination is the whole story.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://www.qlik.com/" rel="noopener noreferrer"&gt;Qlik survey&lt;/a&gt; found mid-career professionals, not Gen Z, emerging as AI’s most active power users. And an arXiv paper from December 2025 nailed it in the title: &lt;a href="https://arxiv.org/abs/2512.09615" rel="noopener noreferrer"&gt;“Professional Software Developers Don’t Vibe, They Control.”&lt;/a&gt; Experienced developers deploy deliberate strategies to manage agent behavior rather than handing over the keys.&lt;/p&gt;

&lt;p&gt;The pattern isn’t younger equals deeper. Experience changes what “deep” looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually built
&lt;/h2&gt;

&lt;p&gt;I’m 45. Director of Information Security by day. Builder of things by night (and also by day, and also at 3 AM).&lt;/p&gt;

&lt;p&gt;I can tell you exactly how I use AI because I built a system that tracks it: 34,000+ messages across AI platforms over two years, logged and queryable.&lt;/p&gt;

&lt;p&gt;The system is called &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;Nexus&lt;/a&gt;. It’s a biographical intelligence platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;250-table Postgres schema&lt;/strong&gt; on hardware I own&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;26 data sources&lt;/strong&gt; across 14 sync intervals (communication, location, health, photos, git activity, AI conversations, financial data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 autonomous agents&lt;/strong&gt; coordinating across hundreds of tools from 10 connected MCP services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local LLM inference&lt;/strong&gt; serving large models on consumer hardware (160GB VRAM across two machines, zero cloud compute for personal data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private mesh network&lt;/strong&gt;, no cloud exposure, every query auditable&lt;/li&gt;
&lt;/ul&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%2Fwg8jw2qqwrg9sp1mvp5h.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%2Fwg8jw2qqwrg9sp1mvp5h.png" alt="Nexus" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent runtime uses a doctrine I spent months refining. The first line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Nexus is best understood as a data and memory platform with bounded reasoning agents on top, not as an unbounded autonomous swarm.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agents reason, explain, coordinate, and propose. They don’t own execution. Processors and handlers do the deterministic work. The database owns state. The agents are an interpretation layer, not a replacement for engineering discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One weekend, with receipts
&lt;/h2&gt;

&lt;p&gt;Two weekends ago a smart ring I’d backed on Kickstarter 25 months earlier finally shipped. By Sunday morning I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully reverse-engineered the undocumented BLE protocol&lt;/li&gt;
&lt;li&gt;Decoded the audio codec&lt;/li&gt;
&lt;li&gt;Written a complete protocol reference document&lt;/li&gt;
&lt;li&gt;Scaffolded two new applications consuming the protocol&lt;/li&gt;
&lt;li&gt;Touched nine repositories&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero sleep. I know because Nexus reconstructed the timeline by cross-referencing git commit timestamps, Claude Code session logs, and ambient capture data from a wearable camera. Two gaps longer than 15 minutes in my activity between 8 PM Saturday and 6 AM Sunday. The longest was 29 minutes.&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%2F1fh5kewdgfjh9o91m3qq.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%2F1fh5kewdgfjh9o91m3qq.png" alt="Timeline" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then the system did something I didn’t ask for. It looked back at the preceding days and showed me the all-nighter wasn’t unusual. The Thursday before, I’d also coded straight through the night shipping infrastructure changes across multiple repos.&lt;/p&gt;

&lt;p&gt;That conversation ended with Claude telling me to talk to a human instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product vs. material
&lt;/h2&gt;

&lt;p&gt;Altman’s generational frame obscures the more useful distinction: &lt;strong&gt;people who use AI as a product&lt;/strong&gt; vs. &lt;strong&gt;people who use it as a material&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Product users open ChatGPT, ask a question, get an answer. Memory is a convenience feature. Someone else runs the infrastructure.&lt;/p&gt;

&lt;p&gt;Material users wire AI into their own systems. They build the memory layer because the commercial one isn’t deep enough. They run local models because privacy isn’t optional when you’re processing decades of personal data. They treat AI like a machinist treats metal: something you shape, cut, and build with.&lt;/p&gt;

&lt;p&gt;When Nexus fabricated a sleep window during my ring weekend (confidently claiming I’d slept 3-4 hours based on a gap in commit timestamps), I challenged it. It ran additional queries across every data source, found continuous activity filling the gap, and corrected itself.&lt;/p&gt;

&lt;p&gt;That kind of interaction requires knowing the tool well enough to catch it lying. That comes from experience, not from growing up with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;I open-sourced the full architecture: &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;github.com/niclydon/nexus-public&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Agent runtime, tool catalog, job system with 93 handlers, knowledge pipeline, LLM router with circuit breakers, distributed autoscaler. MIT license.&lt;/p&gt;

&lt;p&gt;The college students Altman described are building AI judgment naturally, by using it so heavily they start to feel its edges. Practitioners are building it deliberately, with explicit boundaries, approval gates, and doctrine documents that say “the agent proposes, the human decides.”&lt;/p&gt;

&lt;p&gt;Both paths lead to the same place. One arrives by instinct. The other by architecture.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
