The TCP-over-TCP Tax: An Architectural Autopsy

IT
InstaTunnel Team
Published by our engineering team
The TCP-over-TCP Tax: An Architectural Autopsy
The TCP-over-TCP Tax: An Architectural Autopsy
Your tunnel isn’t slow because of your ISP; it’s slow because your packets are stuck in a “double-retransmission” loop. To a systems engineer, high-speed fiber feels like a dial-up connection the moment you wrap a TCP stream inside another TCP stream. This phenomenon, known in networking circles as the TCP-over-TCP Tax (or more dramatically, the TCP Meltdown), is a classic architectural anti-pattern.

In this autopsy, we will dissect the mathematical and algorithmic reasons why SSH tunnels, OpenVPN-TCP, and other nested TCP architectures fail under even minor packet loss, and why modern alternatives like WireGuard and QUIC are the only cure for “sluggish” tunnels.

  1. The Anatomy of Encapsulation
    To understand the tax, we must first look at the stack. When you run an SSH tunnel or a TCP-based VPN, you aren’t just sending data; you are encapsulating a full Inner TCP state machine inside an Outer TCP state machine.

In a standard non-tunneled connection, TCP manages flow control and reliability directly over the IP layer (which is “best-effort” and unreliable). However, in a tunnel:

The Inner TCP (your application) thinks it is talking to a remote host. It manages sequence numbers, ACKs, and a congestion window (cwnd).
The Outer TCP (the tunnel) sees the inner packets as raw payload. It adds its own sequence numbers, ACKs, and its own cwnd.
On a perfect network, this works. But the internet is never perfect.

  1. The Mathematical Autopsy: Why 1% Loss Feels Like 90%
    The core of the “Tax” is how the two layers react to packet loss. In a single-layer TCP connection, the throughput is roughly governed by the Mathis Equation:

Throughput ≤ MSS / (RTT × √p)
Where: – MSS: Maximum Segment Size – RTT: Round-Trip Time – p: Probability of packet loss

When you nest TCP, the probability of loss p doesn’t just reduce throughput linearly; it triggers a synchronization conflict between two independent timers.

The Double-Retransmission Loop
Imagine a single packet is lost on the physical wire:

The Outer TCP’s Response: The tunnel detects the missing packet and stops everything to retransmit it. The tunnel’s cwnd is halved.

The Inner TCP’s Perspective: While the tunnel is busy retransmitting, the inner application packet is “stuck” in the tunnel’s buffer. The Inner TCP sees a massive spike in RTT.

The Meltdown: If the time it takes for the Outer TCP to successfully retransmit is longer than the Inner TCP’s Retransmission Timeout (RTO), the Inner TCP also decides the packet is lost. It triggers its own retransmission.

Now, you have two layers sending the same data. The tunnel is already congested, and the application just doubled the load. This creates a feedback loop where the buffers fill up with redundant retransmissions, pushing the effective RTT to the seconds range.

According to recent research, the control loops in the inner and outer TCPs interfere destructively with each other because the outer TCP masks the connection’s health from the inner TCP. This interference is what transforms minor packet loss into catastrophic performance degradation.

  1. Head-of-Line (HoL) Blocking: The Sequential Prison
    TCP is a reliable byte stream protocol. It guarantees that the application receives data in the exact order it was sent. This is its greatest strength and, in a tunnel, its fatal flaw.

The Sequential Queue
If Packet #1 is lost but Packets #2, #3, and #4 arrive safely, the TCP stack cannot hand Packets #2–4 to the application. They must sit in the buffer until Packet #1 is retransmitted and arrives.

In an SSH tunnel, this effect is global. If you are multiplexing multiple streams (e.g., a database connection and a web request) through one SSH tunnel, a single lost packet in the database stream will block the web request from finishing, even if the web packets arrived perfectly.

Mathematics of the Wait
The probability that a packet is delayed by HoL blocking in a stream with n outstanding packets and loss rate p is roughly 1 – (1-p)^n. As n (the window size) increases to fill a high-bandwidth pipe, the likelihood of a stall approaches 100%, even with p < 0.1%.

This problem becomes even more pronounced in modern high-bandwidth environments where window sizes are necessarily large to fill the bandwidth-delay product.

  1. Real-World Impact: Recent Benchmarks and Case Studies
    WireGuard vs. Traditional VPNs
    Recent comprehensive studies provide concrete evidence of the TCP-over-TCP tax. In VMware environments, WireGuard demonstrated superior TCP throughput at 210.64 Mbps compared to OpenVPN’s 110.34 Mbps, with significantly lower packet loss of 12.35% versus 47.01%.

Field testing has shown even more dramatic results. In practical benchmark conditions, WireGuard was on average 3.3 times faster than OpenVPN, demonstrating the real-world cost of the TCP-over-TCP architecture.

Performance at Scale
Modern VPN solutions have evolved significantly. On gigabit networks, Netmaker achieved consistent data transfer speeds averaging 7.88 Gbits/sec, nearly identical to kernel WireGuard alone at 7.89 Gbits/sec. This near-native performance is possible only because WireGuard avoids the TCP-over-TCP tax entirely.

The performance gap widens further under challenging network conditions. WireGuard achieves its performance through several key factors: a lean design of approximately 4,000 lines of code compared to OpenVPN’s tens of thousands, modern ChaCha20-Poly1305 encryption that runs efficiently on all processors, and kernel integration that processes packets without expensive context switches.

Real-World Deployment Results
Consumer-grade hardware shows impressive WireGuard performance. Recent benchmarks on modern routers demonstrate that WireGuard can achieve nearly full link rate on symmetric gigabit fiber connections, with performance around 1,080 Mbps even on mid-range hardware.

  1. The “Meltdown” and Congestion Control Conflict
    TCP algorithms like CUBIC or NewReno are designed to probe for bandwidth until they see a drop. When two such algorithms are nested, they fight for the same resource:

The Outer TCP tries to fill the pipe.
The Inner TCP also tries to fill the pipe.
When a drop occurs, both back off.
Because the Outer TCP buffers the Inner TCP’s ACKs, the Inner TCP’s RTT estimation becomes “noisy.” It cannot accurately calculate the bandwidth-delay product (BDP).

This “ACK Compression” makes it impossible for the inner connection to ever reach a stable, high-speed steady state. This design can fail when stacking TCP connections, and this type of network slowdown is known as TCP meltdown, which happens when a slower outer connection causes the upper layer to queue up more retransmissions than the lower layer is able to process.

  1. The MTU/MSS “Silent Killer”
    Even if your packet loss is zero, you might still be paying a “fragmentation tax.”

A standard Ethernet frame is 1500 bytes. SSH and VPNs add headers (encryption, encapsulation). If the resulting packet is 1520 bytes, it must be fragmented into two packets.

Fragmentation: – Doubles the packet count – Doubles the interrupt overhead on the CPU – If one fragment is lost, the entire original packet is discarded

For an SSH tunnel, you must “clamp” the Maximum Segment Size (MSS) to ensure the inner TCP segments are small enough to fit inside the tunnel’s payload without fragmentation.

Example: IPTables MSS Clamping

Prevent fragmentation by clamping MSS to Path MTU

iptables -t mangle -A FORWARD -p tcp –tcp-flags SYN,RST SYN -j TCPMSS –clamp-mss-to-pmtu

  1. The Modern Solution: UDP-Based Encapsulation
    The engineering consensus is clear: Tunnels should be stateless.

Why WireGuard Wins
WireGuard uses UDP. If an encrypted packet is lost, WireGuard doesn’t care. It doesn’t retransmit. It doesn’t have a congestion window. It simply passes the responsibility of “reliability” back to the Inner TCP.

Advantages:

No Double-Retransmission: Only one layer (the application) handles recovery.
No HoL Blocking: Packet #2 can be decrypted and delivered even if Packet #1 is missing.
Kernel Integration: WireGuard lives in the Linux kernel (since version 5.6+), avoiding the context-switching overhead that plagues userspace SSH tunnels.
UDP does not retransmit anything by itself, so in the case of UDP, only the inner TCP connection retransmits lost packets, meaning nothing stacks up and the problem is avoided.

Why QUIC (HTTP/3) is the Future
QUIC essentially builds a “smarter” TCP on top of UDP. It supports Multiplexed Streams without HoL Blocking. If one stream in a QUIC tunnel loses a packet, other streams continue unaffected.

QUIC Performance Breakthroughs
Recent real-world deployments demonstrate QUIC’s advantages:

Google’s original paper describing QUIC’s basic mechanisms reported an 8% average reduction in the time it took to load Google search results on desktop and 3.6% on mobile, with up to 16% faster loading for the slowest 1% of users.

For video streaming, the impact is even more dramatic. When looking at YouTube video streaming, researchers found up to 20% less video stalling in countries such as India.

Major CDN providers have validated these benefits at scale. For one large Akamai media customer during a European football live-streaming event popular in Latin America, about 69% of HTTP/3 connections reached a throughput of 5 Mbps or more compared to only 56% of HTTP/2 connections.

Connection Establishment Speed
By using QUIC, HTTP/3 can establish connections up to 33% faster compared to HTTP/2 by combining the transport and TLS handshakes into a single step, reducing the number of round trips needed.

HTTP/3 Adoption in 2024-2025
The protocol has moved from experimental to mainstream. By 2024-2025, major web browsers—Chrome, Firefox, Safari, Edge—all support HTTP/3 by default, with tens of percent of web requests now using HTTP/3, showing growth year-over-year.

Major internet companies are leading the charge. Large companies such as Google and Meta are heavily using HTTP/3, meaning a large chunk of current Internet traffic already uses HTTP/3 today.

Real-World HTTP/3 Performance
Comprehensive benchmarks show consistent improvements. HTTP/3 was faster than its predecessor in every tested case, with the true multiplexed nature meaning there is no Head-of-line blocking happening anywhere on the stack.

Mobile users see particularly significant benefits. A 2025 Akamai report shows HTTP/3 reduces latency by 30% on mobile networks, addressing one of the most challenging environments for traditional TCP.

Why are major platforms investing in the more expensive QUIC/HTTP/3 deployment? QUIC and HTTP/3 are much more expensive to host, costing more CPU time and memory than TCP and HTTP/2 due to more extensive encryption, but this cost is apparently sufficiently offset by the protocols’ benefits.

This is why tools like cloudflared (Cloudflare Tunnels) have migrated to QUIC as their default transport.

  1. The Engineer’s Checklist: Avoiding the Tax
    If your tunnel feels sluggish despite your fiber connection, run through this architectural autopsy:

  2. Stop using TCP-based VPNs
    Switch to WireGuard or Tailscale. If you must use OpenVPN, switch the transport from TCP to UDP.

Why it matters: Despite being considered the more reliable protocol of the two, a TCP VPN ironically only works reliably as long as the line is near perfect, and even if it doesn’t break down completely, there will eventually be duplicate retransmissions that increase latency and noticeably decrease VPN throughput.

  1. Audit your SSH Tunnels
    For development, ssh -L is fine. For production data movement, it is a bottleneck. Consider socat over UDP or a QUIC-based proxy.

  2. Check your MTU
    Use the following command to find the largest packet that passes without fragmentation:

ping -M do -s 1472
If your tunnel is active, this number will be lower (usually 1420 or 1380). Adjust your MTU settings accordingly.

  1. Check for “Bufferbloat”
    Nested TCP exacerbates bufferbloat. Use fq_codel or CAKE as your queue discipline (qdisc) on the tunnel interface to mitigate latency spikes:

Set CAKE qdisc on tunnel interface

tc qdisc replace dev wg0 root cake bandwidth 100mbit

  1. Consider Modern Alternatives
    For web applications, leverage HTTP/3 where possible. For custom tunneling needs, evaluate QUIC-based solutions rather than defaulting to SSH or OpenVPN-TCP.

  2. Understanding the Trade-offs
    When TCP-over-TCP Might Still Be Used
    Despite all the drawbacks, TCP-over-TCP tunnels persist in specific scenarios:

Firewall Traversal: Some corporate networks only allow outbound TCP connections on port 443.
Legacy Systems: Existing infrastructure may not support UDP-based protocols.
Simplicity: SSH tunnels are ubiquitous and require no additional software.
However, recent developments have addressed these concerns. Before committing to solving TCP-over-MPTCP issues, the standard recommendation is clear: if your inner VPN must be TCP, then run it over a UDP-based outer tunnel to sidestep the meltdown problem.

The Performance Reality
Understanding the specific conditions where TCP-over-TCP fails is crucial for system design:

TCP tunnel is a technology that aggregates and transfers packets sent between end hosts as a single TCP connection, but since most applications running on end hosts generally use TCP, two TCP congestion controls operate simultaneously and interfere with each other.

The result? A TCP meltdown results in an outer TCP with a severely reduced congestion window, an inflated retransmission timeout, and a full send buffer, indicating that the inner TCP cannot write and that ACKs are not being sent in either direction.

  1. Looking Forward: The Post-TCP Internet
    The shift from TCP to UDP-based protocols represents a fundamental rethinking of internet transport:

QUIC’s Architectural Innovations
QUIC heavily integrates with the Transport Layer Security (TLS) protocol, and with QUIC, TLS also encrypts large parts of the QUIC protocol itself, meaning that metadata such as packet numbers and connection-close signals which were visible to all middleboxes in TCP are now only available to the client and server in QUIC.

This integration provides both security and performance benefits, reducing connection setup latency while increasing privacy.

Stream Multiplexing Done Right
Streams are known to HTTP but not to TCP, and individual frames of different streams are numbered and transmitted as ordered segments in TCP, so if a segment gets lost in transit, TCP will resend that after a timeout, blocking all other segments in the connection.

QUIC solves this by making streams a first-class concept at the transport layer, eliminating the head-of-line blocking that plagues HTTP/2 over TCP.

Mobile and Unreliable Networks
QUIC can persist a connection across network changes, unlike TCP which requires the same endpoint (IP and port) throughout the connection. This allows seamless handoffs when users switch between WiFi and mobile data—a common scenario TCP was never designed to handle.

Conclusion
The TCP-over-TCP Tax is a fundamental conflict of interest between two layers of reliability. By trying to be “too reliable,” the tunnel becomes unusable.

The evidence is overwhelming: – WireGuard consistently outperforms TCP-based VPNs by 2-3x – HTTP/3 delivers measurable improvements in real-world deployments – Major internet platforms have adopted QUIC despite higher CPU costs – Modern kernels include WireGuard natively, signaling industry consensus

In the world of hard engineering, the fastest path is often the one that knows when to let a packet go. Use UDP for your tunnels, let TCP handle the data at the application layer, and stop paying the tax.

The future of internet transport is clear: stateless tunneling with UDP-based protocols like WireGuard and QUIC. As networks become faster and more complex, the architectural lessons of the TCP-over-TCP problem become even more relevant. Choose your protocols wisely, understand the trade-offs, and build systems that work with network realities rather than against them.

Additional Resources
WireGuard Official Documentation
IETF QUIC Working Group
RFC 9000 – QUIC Transport Protocol
RFC 9114 – HTTP/3
Cloudflare Learning Center – What is QUIC?
Testing Your Own Setup
To benchmark your VPN or tunnel performance:

Test throughput with iperf3

iperf3 -c -t 30 -P 4

Test latency

ping -c 100

Check for packet loss

mtr -c 100
Compare results with and without your tunnel active to quantify the performance impact.

Related Topics

TCP-over-TCP performance tax, tunnel latency math, Head-of-Line Blocking (HoL), SSH tunnel benchmarks 2026, WireGuard vs SSH speed, TCP Meltdown effect, congestion control conflict, retransmission timeout (RTO) math, packet loss in tunnels, TCP window scaling, additive increase multiplicative decrease (AIMD), UDP-based tunneling, QUIC vs TCP performance, HTTP/3 tunnel benchmarks, network stack autopsy, tunneling overhead calculation, MTU fragmentation tunnels, packet encapsulation tax, double ack storm, TCP keepalive vs tunnel heartbeat, low-latency networking 2026, Wi-Fi 7 tunnel jitter, 6G network slice latency, LocalCan performance data, zrok vs ngrok speed, high-throughput tunneling, kernel-space vs user-space tunnels, BBR congestion control, CUBIC vs Reno for tunnels, stream multiplexing overhead, zero-copy networking, eBPF network monitoring, XDP packet processing, WireGuard throughput formula, Padhye’s formula for throughput, network bufferbloat, tail drop vs RED, persistent tunnel stability, distributed systems networking, VPC peering vs tunneling, hardware-accelerated crypto tunnels, PQC tunnel overhead, latency-sensitive devops, 2026 networking trends, architectural autopsy, infrastructure engineering, network protocol debt, optimized dev experience (DevEx), microservices network latency

I Built a Zero-Dependency Visual JSON Flow Editor in Vanilla JS for the Camino Flow Engine

Main view of the flow editor

A few days ago, I introduced Camino-a lightweight, rule-driven JSON flow engine for Java.
📖 Stop Hardcoding Your Workflows: Meet the Rule-Driven JSON Flow Engine for Java

The goal of Camino was simple: give Spring Boot developers an alternative to heavyweight BPMN tools like Camunda or Flowable. Instead of dealing with massive XML files, steep learning curves, and database bloat, Camino lets you map your service layer execution dynamically using a clean, readable JSON structure and MVEL expressions.

But there was a catch. As your business logic scales, hand-writing nested JSON arrays with exact id and nextId references gets tedious. You lose the “visual map” aspect that makes traditional BPMN tools so appealing to system architects and business analysts.

I needed a way to visually design these JSON flows. And because I wanted to keep the “lightweight” philosophy of the Camino engine, I decided to build the frontend without any dependencies. No React. No NPM install.

Meet the Camino Flow Editor-a zero-dependency, Vanilla JS visual canvas for the Camino engine.

Main view of the flow editor

The Problem: JSON is for Machines, Visuals are for Humans

When you define a flow in the Camino Java engine, it expects a schema like this:

{
    "name": "myFirstFlow",
    "blocks": [
        {
            "id": "f2966cbd...",
            "type": "START",
            "nextId": "e21464b9..."
        },
        {
            "id": "e21464b9...",
            "name": "checkNameIntersection",
            "type": "INTERSECTION",
            "conditions": [
                { 
                    "name": "yes", 
                    "expression": "name.equals('George')", 
                    "nextId": "3a18a..." },
                { 
                    "name": "no", 
                    "defaultCondition": true, 
                    "nextId": "fa337..." }
            ]
        }
    ]
}

When you define a flow in the Camino Java engine, it expects a schema where blocks are linked by matching IDs. The engine is incredibly flexible-it doesn’t care if your IDs are long UUIDs or simple strings like “fetch_user” or “step_1”, as long as the nextId property correctly points to the id of the target block.

However, manually maintaining these links in a text editor is a recipe for a typo. The Camino Flow Editor solves this by giving you an infinite drag-and-drop canvas.

When you select a “Target” in the UI, the editor handles the “plumbing”-automatically mapping the relationship between the source and destination IDs. You simply connect the blocks, configure the rules, and click Save. The editor exports the exact JSON schema your Spring Boot backend expects.

How I Built It (Without Frameworks)

Building a node-based visual editor using just HTML, CSS, and Vanilla JS presented some really fun architectural challenges.

1. Compact Canvas & Modal Configuration

One of the issues with visual flow editors is cognitive overload. When every block displays all of its configuration properties, a 10-step flow takes up three monitors.

To solve this, Camino Flow Editor keeps the canvas entirely compact. Blocks only show their Name and Type. All of the heavy lifting-defining the Java AbstractActionHandler names, writing MVEL expressions, and setting fallback “Default” conditions-is tucked away in a dedicated modal window accessible by double-clicking.

Edit intersection modal view

2. Dynamic SVG Orthogonal Routing

I wanted smooth, Manhattan-style (orthogonal) routing that stays perfectly vertical or horizontal. Without a heavy library like jsPlumb, I had to build a custom geometry engine.

There is a transparent SVG layer draped over the DOM workspace. Whenever you move a block or change a target dropdown in the config modal, the engine calculates the coordinates, paths around adjacent blocks to prevent “backward loop” overlapping, and draws beautiful 15px-radius rounded corners terminating in an arrowhead.

Conditional paths after the intersection evaluation

3. A Deep-Copy History Engine

You can’t have a serious editor without Ctrl+Z.

I implemented a comprehensive history stack. The editor takes a lightweight snapshot of the underlying state object before every destructive action (creating a block, deleting one, changing an MVEL expression, or lifting your mouse after a drag). Hit Ctrl+Z to undo or Ctrl+Shift+Z to redo, and the SVG canvas repaints itself instantly.

The Perfect Workflow

With these two projects combined, the workflow is incredibly fast:

  1. Draw it: Open CaminoFlowEditor.html locally. Drag and drop your workflow, add your MVEL routing logic, and click Save.
  2. Drop it: Move the exported .json file into your Spring Boot src/main/resources/camino folder.
  3. Code it: Create your AbstractActionHandler Java classes to match the names you defined in the UI.
  4. Run it: Pass your initial context HashMap to the FlowExecutor, and watch Camino execute your visual map.

No database schemas. No heavy XML parsing. No bloated dependencies. Just pure, decoupled business logic.

Try It Out!

Both the Java Engine and the Flow Editor are completely open-source. I would love for you to try them out, fork them, and let me know what you think!

If you missed the deep dive into how the backend engine works, check out the original article here:
📖 Stop Hardcoding Your Workflows: Meet the Rule-Driven JSON Flow Engine for Java

🎨 Frontend Editor: [https://github.com/a-koro/CaminoFlowEditor]
⚙️ Java Backend Engine: [https://github.com/a-koro/camino]
🚀 Working Demo: [https://github.com/a-koro/caminoDemoApplication]

What Laravel 13 Actually Changes for AI Development

Laravel 13 dropped a production-stable AI SDK on release day and nobody’s talking about what it actually replaces. Here’s the full breakdown.

Laravel 13 launched on March 17, 2026 , and Taylor Otwell kept his Laracon EU promise: zero breaking changes, a clean upgrade from Laravel 12, and one headline shift that resets the default approach to AI in PHP. The Laravel 13 AI SDK is now production-stable, first-party, and catalogued inside the official Laravel documentation as a core concern — not a side-effect of a community package you bolt on after you’ve already made architectural decisions you’ll later regret.

This is not a release you skim for changelog bullet points and move on. For anyone building AI-powered features on Laravel, this release fundamentally changes how you should be structuring that work. Let’s go through it properly.

Why Laravel 13 Is an AI-First Release

Laravel 13 continues Laravel’s annual release cadence with a focus on AI-native workflows, stronger defaults, and more expressive developer APIs. The official framing is “minimal breaking changes,” and that’s accurate — but undersells it. The infrastructure laid here directly shapes whether your AI features remain maintainable at scale or turn into the kind of controller-level spaghetti you spend the next two years paying down.

The Laravel AI SDK moves from beta to production-stable on the same day as Laravel 13. It is included as a first-party package and gives you a single, provider-agnostic interface for text generation, tool-calling agents, image creation, audio synthesis, and embedding generation. The SDK handles retry logic, error normalisation, and queue integration behind the scenes. You get all of that without writing a custom abstraction layer, and without coupling your application to a specific provider’s SDK contract.

That last point deserves more weight than the release notes give it.

The Laravel AI SDK: What It Actually Gives You

Text Generation and Agents

The simplest entry point looks like this:

use AppAiAgentsSalesCoach;
$response = SalesCoach::make()->prompt('Analyse this sales transcript...');
return (string) $response;

With the AI SDK, you can build provider-agnostic AI features while keeping a consistent, Laravel-native developer experience. That means your SalesCoach agent does not care whether it’s backed by OpenAI, Anthropic, or Google Gemini. You wire the provider in config/ai.php and the agent contract stays unchanged.

The default models used for text, images, audio, transcription, and embeddings are now configurable in your application’s config/ai.php file. This gives you granular control over the exact models you’d like to use if you don’t want to rely on the package defaults. A minimal configuration targeting Anthropic looks like this:

// config/ai.php
return [
    'models' => [
        'text' => [
            'default'  => env('AI_TEXT_MODEL', 'claude-sonnet-4-6'),
            'cheapest' => 'claude-haiku-4-5-20251001',
            'smartest' => 'claude-opus-4-6',
        ],
    ],
];

You are not hardcoding a model string into a service class. You are not parsing a .env file in three different controllers. One config file governs the whole application. If you need to roll back a model mid-incident, it’s one value and a php artisan config:cache.

Images and Audio

For visual generation use cases, the SDK offers a clean API for creating images from plain-language prompts. For voice experiences, you can synthesize natural-sounding audio from text for assistants, narrations, and accessibility features.

use LaravelAiImage;
use LaravelAiAudio;
// Image generation
$image = Image::of('A product shot of a minimalist desk lamp')->generate();
$rawContent = (string) $image;
// Audio synthesis
$audio = Audio::of('Your order has been confirmed.')->generate();
$rawContent = (string) $audio;

The fluent API is consistent across modalities. That consistency is not accidental. It means a developer who’s only worked with text generation can pick up image or audio synthesis in minutes — no mental context switch, no separate SDK documentation to parse.

Embeddings and the Str Helper

Here’s where it gets genuinely interesting. Embedding generation is wired directly into Laravel’s Strhelper:

use IlluminateSupportStr;
$vector = Str::of('Napa Valley has exceptional Cabernet Sauvignon.')->toEmbeddings();

That’s not a convenience method. That’s the framework signalling that embeddings are now a first-class data type. You’re not reaching for a one-off utility class — you’re calling a helper that sits alongside Str::slug() and Str::limit() as a standard part of the toolkit.

[Architect’s Note] The fact that toEmbeddings() lives on the Strhelper rather than a dedicated Embeddingfacade is a deliberate design choice. It keeps embedding generation composable with the rest of your string manipulation pipeline. Chain it after a limit() call to trim tokens before you generate — or after markdown() to clean formatting before vectorising. Think of it as the framework nudging you toward preprocessing being part of the same expression.

Native Vector Search: Semantic Queries Without a Search Engine Subscription

Laravel 13 deepens its semantic search story with native vector query support, embedding workflows, and related APIs. These features make it straightforward to build AI-powered search experiences using PostgreSQL and pgvector, including similarity search against embeddings generated directly from strings.

The query builder extension looks like this:

$documents = DB::table('documents')
    ->whereVectorSimilarTo('embedding', 'Best restaurants in Cape Town')
    ->limit(10)
    ->get();

Under the hood, this generates a pgvector-compatible cosine similarity query against your Postgres database. No Elasticsearch cluster. No Typesense subscription. No Algolia bill. For a significant number of use cases — internal knowledge bases, product catalogue search, customer support retrieval — Postgres with pgvector is more than sufficient, and the operational overhead is zero if you’re already on Postgres.

The migration for the vector column uses a new column type:

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->text('content');
    $table->vector('embedding', 1536); // dimension matches your model's output
    $table->timestamps();
});

A realistic ingestion pipeline that generates and stores embeddings looks like this:

use IlluminateSupportStr;
use AppModelsDocument;
class IngestDocumentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public function __construct(private readonly string $content) {}
    public function handle(): void
    {
        $embedding = Str::of($this->content)->toEmbeddings();
        Document::create([
            'content'   => $this->content,
            'embedding' => $embedding,
        ]);
    }
}

Dispatch it from a controller, and your embedding pipeline is queued, retried on failure, and observable via Horizon — exactly the same way you’d treat any other background job.

[Production Pitfall] Embedding API calls are slow — typically 300–800ms depending on payload size and provider latency. Do not generate embeddings synchronously in a request lifecycle. Every embedding generation call belongs in a queued job, period. Under load, synchronous embedding generation will saturate your PHP-FPM pool faster than almost anything else. We’ve seen this exact pattern cause cascading timeouts in staging under traffic replay. Don’t let it reach production.

Provider Agnosticism: What It Means Practically

Switch providers by changing one config value. Teams that offer professional Laravel development services can now build AI-powered features inside a standard Laravel project — no custom abstraction layers, no third-party SDK gymnastics required.

This matters more than it reads. Prior to the AI SDK going stable, the common pattern was to inject an OpenAI client through the Service Container, write a thin wrapper around it, and pray that the wrapper held when you inevitably needed to test responses or swap a model mid-sprint. Most teams ended up with one of three things: a god-service that knew too much, a leaky abstraction that only worked for their current provider, or no abstraction at all and raw Http::post() calls in controllers.

The AI SDK replaces all three anti-patterns with a single, testable, swappable interface that the framework itself maintains. If the underlying provider deprecates an API method, the fix lands in a framework update — not in your codebase.

Here’s how you’d swap to a different provider for a specific agent without touching the rest of the application:

use LaravelAiFacadesAi;
use LaravelAiEnumsProvider;
$response = Ai::using(Provider::Anthropic)
    ->withModel('claude-opus-4-6')
    ->prompt('Summarise this legal document...');

That using() call overrides the config/ai.php default for this invocation only. You can do per-request provider overrides without touching global configuration. That’s useful in multi-tenant applications where different plans map to different model tiers.

If you’re already running a custom abstraction over the OpenAI PHP SDK, we wrote about how to migrate that pattern properly — including how to handle cost visibility and telemetry — in Production-Grade AI Architecture in Laravel: Contracts, Governance & Telemetry. The AI SDK doesn’t replace the need for those architectural decisions; it gives you a better foundation to implement them on.

Tool-Calling Agents: Building Agentic Workflows Natively

The AI SDK’s agent support deserves its own section. Tool-calling — the mechanism by which an LLM decides to invoke a function in your application — is the primitive that makes agentic workflows possible.

A basic agent with tools looks like this:

namespace AppAiAgents;
use LaravelAiAgent;
use LaravelAiAttributesTool;
class OrderAssistant extends Agent
{
    protected string $model = 'claude-sonnet-4-6';
    protected string $instructions = 'You are a helpful order management assistant.';
    #[Tool('Look up the status of an order by ID')]
    public function getOrderStatus(int $orderId): string
    {
        $order = Order::findOrFail($orderId);
        return "Order #{$orderId} is currently {$order->status}.";
    }
    #[Tool('List all open orders for a given customer')]
    public function listOpenOrders(int $customerId): array
    {
        return Order::where('customer_id', $customerId)
            ->where('status', 'open')
            ->pluck('id')
            ->toArray();
    }
}

The #[Tool] attribute wires the method directly into the model’s tool-calling interface. The SDK handles the round-trip: the model sees the tool definition, decides to call it, the SDK invokes your method, and the result is injected back into the conversation automatically.

Notice that getOrderStatusand listOpenOrdersare standard Eloquent queries. There is nothing AI-specific about the business logic inside the tools. This is the correct separation. Your tools are Laravel code. The AI SDK manages the protocol layer between your code and the model.

[Edge Case Alert] Tool-calling agents can enter infinite loops if the model repeatedly decides to call the same tool with the same arguments — this happens when the tool’s return value doesn’t advance the conversation’s goal. Always set a maxStepslimit on your agents and handle MaxStepsExceededExceptionexplicitly. Defaulting to unlimited steps in production is asking for a runaway API bill.

$response = OrderAssistant::make()
    ->maxSteps(10)
    ->prompt("What's the status of order 99?");

If you’re building more sophisticated multi-step agentic workflows and need schema validation on tool outputs — which you almost certainly will once you’re past demos — the Hardening Laravel Agentic Workflows: Schema Validation Against LLM Hallucinations guide covers exactly that.

Queue Integration and config/ai.php as Operational Infrastructure

One under-discussed aspect of the AI SDK is how it integrates with Laravel’s Queue system. Transcription now supports timeouts, giving you better control in production workloads and preventing long-running requests from tying up workers.

$transcript = Transcription::fromPath('./podcast.mp3')
    ->timeout(240)
    ->generate(Lab::ElevenLabs);

That timeout() call maps directly to the queue worker’s job timeout. If you’re already familiar with $timeout on job classes, this is the same mechanism — now surfaced at the SDK level so you don’t have to know to set it yourself.

Pair this with Laravel 13’s new Queue::route() method for clean queue topology:

// app/Providers/AppServiceProvider.php
use IlluminateSupportFacadesQueue;
Queue::route([
    IngestDocumentJob::class   => 'embeddings',
    GenerateImageJob::class    => 'ai-images',
    TranscribeAudioJob::class  => 'transcription',
]);

AI jobs should never share a queue with your standard application jobs. Embedding generation and image synthesis calls have entirely different latency profiles and retry semantics than sending a welcome email. The new Queue::route() method lets you define which queue and connection each job class uses from a single location in a service provider. Previously, teams either set queue properties on each job class or repeated the configuration at every dispatch site.

Centralise that in a Service Provider and you’ve got one place to retune queue topology when your AI workload changes.

Error Handling: What the SDK Does, and What It Doesn’t

The AI SDK handles retry logic and error normalisation internally, but that does not mean you write no error handling. It means you handle fewer low-level concerns. The contracts you still own:

use LaravelAiExceptionsAiProviderException;
use LaravelAiExceptionsRateLimitException;
use LaravelAiExceptionsContentFilterException;
try {
    $response = SalesCoach::make()->prompt($userInput);
} catch (RateLimitException $e) {
    // The SDK exhausted its internal retry budget — back off and re-queue
    InvokeSalesCoachJob::dispatch($userInput)->delay(now()->addSeconds(60));
} catch (ContentFilterException $e) {
    // The provider flagged the input — log it, don't retry
    Log::warning('Content filter triggered', ['input_hash' => hash('sha256', $userInput)]);
    return response()->json(['error' => 'Input could not be processed.'], 422);
} catch (AiProviderException $e) {
    // Unknown provider error — log full context, fail gracefully
    Log::error('AI provider failure', ['message' => $e->getMessage()]);
    return response()->json(['error' => 'Service temporarily unavailable.'], 503);
}

The SDK’s internal retry logic handles transient 5xx errors and network timeouts. RateLimitException is thrown when the SDK’s retry budget is exhausted — at that point, you need application-level backpressure, not another retry. Handle these as distinct failure modes. They are.

[Word to the Wise] Logging $e->getMessage() on a provider exception often includes the raw prompt in the message string depending on the provider. Sanitise before logging in any context where the prompt may contain user PII. This is not a theoretical concern — it’s the kind of thing that shows up in a GDPR audit.

The Bigger Picture: Laravel’s AI Roadmap Signal

The Laravel AI SDK going stable on the same day as Laravel 13 is a deliberate signal: the framework’s roadmap is now AI-first. The docs now include a dedicated AI section with entries for the AI SDK, MCP integration, and Laravel Boost — Taylor’s AI-assisted development tooling. The installation docs now include a dedicated “Laravel and AI” section, and existing apps are pointed to Laravel Boost for AI-assisted development workflows.

Read that as a directional commitment. The primitives shipped in Laravel 13 — provider-agnostic text generation, first-class embeddings, native vector queries, tool-calling agents — are the foundation. What gets built on top of them in 13.x point releases and Laravel 14 will assume they exist. If you’re planning AI features over the next 12 months and you’re not on Laravel 13, you’re doing that planning on a weaker foundation than the framework now offers.

The upgrade path is genuinely low-friction for most apps. The official release notes emphasise minimal breaking changes, and the official upgrade guide estimates about 10 minutes for many applications. The real risk is in custom cache behaviour, request forgery edge cases, and hand-rolled framework integrations.

If you’re running a custom AI integration today — your own OpenAI service class, your own retry decorator, your own token-counting middleware — the migration question is not “should I switch to the AI SDK?” It’s “how quickly can I make the switch before the ecosystem diverges enough that porting gets expensive?” For token tracking and rate limiting specifically, see Laravel AI Middleware: Token Tracking & Rate Limiting — that pattern ports cleanly onto the SDK’s event hooks.

Upgrade Checklist: AI-Specific Steps for Laravel 13

Before you run composer require laravel/framework:^13.0, address the following:

1. PHP version. Laravel 13 requires PHP 8.3 as a minimum. PHP 8.5, released November 2025, is also supported by Laravel 13, bringing further JIT improvements and native URI handling. Check your server runtime before anything else.

2. Remove redundant provider SDKs. If you’re injecting openai-php/client or anthropic/anthropic-sdk-php directly via the Service Container, evaluate whether the AI SDK replaces that dependency entirely. In most cases, it does. Keeping both creates competing abstraction layers.

3. Audit your queue configuration. With Queue::route() now available, centralise your AI job routing in AppServiceProvider. If you’re currently setting public string $queue = 'ai' on individual job classes, that works — but Queue::route() is cleaner and easier to update without touching job classes.

4. Generate and store your config/ai.php. The SDK expects this file. Publish it with:

php artisan vendor:publish --tag=ai-config

Then pin your model names explicitly rather than relying on package defaults. Model defaults change between SDK minor releases. You want to control when your application picks up a new default model — not discover that it happened because a patch updated the SDK.

5. Add pgvector to your Postgres instance if you plan to use whereVectorSimilarTo. The extension is available in RDS, Supabase, and Neon without additional configuration. On self-managed Postgres, install it with:

//sql
CREATE EXTENSION IF NOT EXISTS vector;

6. Test your request forgery protection. Laravel 13 formalises PreventRequestForgerywith origin-aware verification on top of token-based CSRF. If you have custom CSRF handling or API routes with unusual origin configurations, test them explicitly before deploying.

What’s Not Yet There

Let’s be direct about the gaps.

Streaming responses — where the model outputs tokens progressively rather than in a single payload — are not yet a first-class concern in the AI SDK’s stable release. For chat interfaces that need token streaming, you’ll still be reaching for provider-specific solutions or custom SSE handling. Watch the 13.x changelog; this is an obvious next primitive.

Multi-modal input — sending images or audio to the model rather than from it — is also not yet documented in the stable SDK surface area. It’s likely coming in a 13.x release, but don’t plan around it until it ships.

The vector search integration is Postgres-only for now. If you’re on MySQL or MariaDB, whereVectorSimilarTois not available. For those stacks, external vector stores (Pinecone, Qdrant, Weaviate) remain the path, and you’ll need your own integration layer.

Final Thoughts

Laravel 13 is not a rewrite. It does not break your application. What it does is establish a new baseline for what “standard Laravel AI work” looks like — and that baseline is considerably higher than it was under Laravel 12. The teams that adopt this now and build their AI features against SDK contracts rather than raw provider clients will have significantly less migration debt when Laravel 14 arrives and extends these primitives further.

Get on PHP 8.3, publish config/ai.php, and start moving your AI layer onto the SDK. The upgrade cost is low. The cost of not doing it compounds.

For the official release notes and full SDK documentation, see the Laravel 13 Release Notes and the Laravel AI SDK documentation directly.

Real-time messaging with Mercure SSE in PHP

Ahnii!

Mercure lets you push real-time updates to browsers using server-sent events (SSE), without WebSocket complexity. This post covers how Minoo, a community platform built on the Waaseyaa framework, uses Mercure for real-time messaging with threads, user blocking, and email notification digests.

How the messaging layers fit together

The messaging system has four layers:

  1. Entities — Thread, Participant, and Message stored in SQLite
  2. Controller — handles HTTP requests for sending messages and listing threads
  3. MercurePublisher — pushes new messages to subscribed browsers via SSE
  4. MessageDigestCommand — CLI command that emails unread message summaries on a cron schedule

When a user sends a message, the controller saves it to the database, then publishes an event through Mercure. Every browser with that thread open receives the message instantly. Users who are offline get an email digest every four hours.

The MercurePublisher

The publisher lives in the waaseyaa/mercure framework package. It takes a hub URL and JWT secret, then posts updates to the Mercure hub:

final class MercurePublisher
{
    public function __construct(
        private readonly string $hubUrl,
        private readonly string $jwtSecret,
    ) {}

    public function publish(string $topic, array $data): bool
    {
        if (!$this->isConfigured()) {
            return false;
        }

        $ch = curl_init($this->hubUrl);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $this->buildPostBody($topic, $data),
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $this->generateJwt(),
                'Content-Type: application/x-www-form-urlencoded',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
        ]);

        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        return $result !== false && $httpCode >= 200 && $httpCode < 300;
    }
}

The isConfigured() check lets the publisher degrade gracefully in environments where Mercure is not running (like local development without Docker). If the hub URL or JWT secret is empty, publish() returns false without throwing.

Publishing a message from the controller

When the messaging controller saves a new message, it publishes to a topic scoped to the thread:

$this->publishMercure("/threads/{$threadId}", [
    'type' => 'message',
    'message' => [
        'id' => (int) $message->id(),
        'thread_id' => $threadId,
        'sender_id' => $userId,
        'body' => $body,
        'created_at' => $now,
    ],
]);

The topic follows the pattern /threads/{id} so the JavaScript client knows what to subscribe to. The private publishMercure method delegates to the MercurePublisher with null-safe access ($this->mercurePublisher?->publish(...)).

Subscribing from the browser

The frontend uses the native EventSource API to subscribe to the Mercure hub. No library needed:

const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', `/threads/${threadId}`);

const eventSource = new EventSource(url, { withCredentials: true });

eventSource.onmessage = (event) => {
    try {
        const data = JSON.parse(event.data);
        if (data.type === 'message') {
            appendMessage(data.message);
        }
    } catch {
        // Ignore malformed events
    }
};

The hubUrl (typically /.well-known/mercure) is passed from the server template. EventSource handles reconnection automatically. If the connection drops, the browser reconnects and receives any events it missed (Mercure tracks the Last-Event-ID header). The MercureConnection class in Minoo also includes a polling fallback for environments where SSE is unavailable.

Enforcing user blocks at thread creation

When a user creates a thread, the controller checks the block relationship in both directions before allowing it:

$blockStorage = $this->entityTypeManager->getStorage('user_block');
foreach ($participantIds as $participantId) {
    if ($participantId === $creatorId) {
        continue;
    }

    $blocked = $blockStorage->getQuery()
        ->condition('blocker_id', $participantId)
        ->condition('blocked_id', $creatorId)
        ->range(0, 1)
        ->execute();

    if ($blocked !== []) {
        return $this->json(['error' => 'Cannot message a user who has blocked you'], 403);
    }

    $blocking = $blockStorage->getQuery()
        ->condition('blocker_id', $creatorId)
        ->condition('blocked_id', $participantId)
        ->range(0, 1)
        ->execute();

    if ($blocking !== []) {
        return $this->json(['error' => 'Cannot message a user you have blocked'], 403);
    }
}

The check runs against the user_block entity storage. It queries both directions: whether any participant has blocked the creator, and whether the creator has blocked any participant. Either case returns a 403. This prevents threads from being created between blocked users, rather than silently dropping messages after the fact.

Email digests for offline users

Not everyone is online when a message arrives. The MessageDigestCommand runs on a cron schedule and emails summaries of unread messages:

# Cron entry (every 4 hours)
0 */4 * * * cd /home/deploy/minoo/current && php bin/waaseyaa messaging:digest

The command queries for messages created since the last digest run where the recipient has not read them. It groups messages by thread and sends one email per recipient with all their unread threads. This avoids flooding inboxes with individual notification emails.

Thread data model

The data model uses three tables:

  • thread — holds the thread metadata (title, created_at, type)
  • thread_participant — junction table linking threads to users, with a last_read_at timestamp
  • message — the actual messages, with thread_id, sender_id, body, and created_at

The last_read_at timestamp on the participant record is how the digest command knows which messages are unread. When a user opens a thread, the frontend updates last_read_at to the current time.

Beyond the server side

The JavaScript modules that handle the UI (typing indicators, scroll-to-bottom, unread badges) and the infrastructure for deploying Mercure alongside Caddy on a VPS are separate topics. The Mercure hub also supports additional event types beyond messages: Minoo publishes typing, read, message_edited, and message_deleted events through the same /threads/{id} topic, giving the frontend a complete real-time view of thread activity.

Baamaapii

HTML vs Markdown vs SOM: Which Format Should Your AI Agent Use?

Every AI agent that browses the web faces the same question: how do you represent a web page to a language model?

The default answer, raw HTML, is expensive and slow. A typical page dumps 30,000+ tokens into your context window, most of it CSS classes and layout divs. But what are the actual alternatives? And do they work?

We ran WebTaskBench, 100 tasks across GPT-4o and Claude Sonnet 4, to find out. The results surprised us.

The Three Representations

When an agent needs to understand a web page, there are three common approaches:

1. Raw HTML

The DOM as-is. Every <div>, every class="sc-1234 flex items-center gap-2", every inline script. This is what most agents send today.

<div class="sc-1234 flex items-center gap-2 px-4 py-2">
  <a href="/about" class="text-blue-500 hover:underline
     font-medium tracking-tight text-sm">About</a>
  <span class="text-gray-400">|</span>
  <a href="/pricing" class="text-blue-500 hover:underline
     font-medium tracking-tight text-sm">Pricing</a>
</div>

Pros: Complete fidelity to the DOM. No information lost.

Cons: 80-95% of tokens are noise (styling, scripts, tracking). Expensive. Slow.

2. Markdown

Strip the HTML to readable text, preserving structure through Markdown conventions. This is what tools like Jina Reader and many scraping libraries produce.

[About](/about) | [Pricing](/pricing)

Pros: Dramatically fewer tokens. Human-readable.

Cons: Loses interactive elements. No way to know what’s clickable. Navigation tasks become guesswork.

3. SOM (Semantic Object Model)

A structured JSON representation that preserves meaning and interactivity while stripping presentation noise. Each element includes its semantic role and available actions.

{
  "role": "navigation",
  "elements": [
    { "role": "link", "text": "About", "id": "e_a1b2c3", "attrs": {"href": "/about"}, "actions": ["click"] },
    { "role": "link", "text": "Pricing", "id": "e_d4e5f6", "attrs": {"href": "/pricing"}, "actions": ["click"] }
  ]
}

Pros: Minimal tokens. Preserves interactivity. Clear semantic roles.

Cons: Requires a SOM-aware fetcher (like Plasmate).

Token Cost Comparison

We measured input tokens across 50 web pages (news sites, documentation, e-commerce, government sites, social platforms). The differences are stark:

Token Cost Comparison:

  • HTML: 33,181 average input tokens (1.0x)
  • SOM: 8,301 average input tokens (4.0x fewer)
  • Markdown: 4,542 average input tokens (7.3x fewer)

Markdown wins on raw token count, it strips everything. But tokens aren’t the whole story.

Cost Per 1,000 Pages (at $3/M input tokens)

Cost Per 1,000 Pages (at $3/M input tokens):

  • HTML: $99.54 (baseline)
  • SOM: $24.90 (75% savings)
  • Markdown: $13.63 (86% savings)

If you’re just extracting text, Markdown is cheaper. But if your agent needs to interact with pages, click buttons, fill forms, navigate, Markdown falls apart.

The Latency Surprise

Here’s where it gets interesting. We expected Markdown to be fastest (fewest tokens = fastest inference). That’s true for GPT-4o:

GPT-4o Latency (seconds)

GPT-4o Latency (seconds):

  • HTML: 2.7s
  • Markdown: 1.9s
  • SOM: 1.4s (fastest)

SOM beats both. Why? Two reasons:

  1. Structured input parses faster. JSON with clear roles lets the model skip the “what is this?” step.
  2. Less ambiguity = shorter reasoning chains. When a link is explicitly marked "role": "link", "actions": ["click"], the model doesn’t need to infer interactivity from context.

Claude Sonnet 4 Latency (seconds)

Claude Sonnet 4 Latency (seconds):

  • HTML: 16.2s
  • Markdown: 25.2s (slowest)
  • SOM: 8.5s (fastest)

Wait, Markdown is slower than HTML on Claude? Yes. And SOM is nearly 3x faster than Markdown.

Claude appears to struggle with ambiguous Markdown when the task requires understanding page structure. The model spends more time reasoning about what elements are clickable, what actions are available, and how to express those actions. With SOM, that information is explicit.

Category Breakdown

Not all tasks are equal. We tested extraction, comparison, navigation, summarization, and adversarial tasks (noisy pages with heavy chrome).

HTML/SOM Token Ratio by Category

HTML/SOM Token Ratio by Category:

  • Extraction: 2.2x (SOM wins, but margin is smaller)
  • Comparison: 3.9x (Multi-item pages benefit from structure)
  • Summarization: 3.9x (Similar to comparison)
  • Navigation: 5.4x (Interactivity data is dense in SOM)
  • Adversarial: 6.0x (Anti-bot clutter inflates HTML massively)

For adversarial pages (cookie banners, heavy JavaScript, ad-filled layouts), HTML explodes with noise while SOM stays lean. The 6x ratio means you’re paying 6x more for HTML on the hardest pages.

Where Markdown Fails

Markdown works great for “read this article and summarize it.” It breaks down for:

  • Form filling: Markdown can’t represent input fields, dropdowns, or submit buttons
  • Navigation: No reliable way to know which text is a clickable link vs decorative
  • Stateful interactions: Multi-step flows (add to cart, checkout) require element references
  • Dynamic content: JavaScript-rendered content often doesn’t survive text conversion

When to Use What

Use Markdown when:

  • Pure text extraction (summarize this article)
  • No interaction needed
  • Budget is the only constraint
  • You control the source (your own docs, known-good pages)

Use SOM when:

  • Agents need to click, type, or navigate
  • Multi-step workflows
  • Unknown or adversarial pages
  • Latency matters (SOM is fastest on both models)
  • You want consistent structure across diverse sites

Use HTML when:

  • You need pixel-perfect DOM fidelity
  • Building a browser automation tool that maps directly to CSS selectors
  • Debugging what the page actually contains

The honest recommendation: default to SOM unless you have a specific reason not to. It’s faster, cheaper than HTML, and handles interactive tasks that Markdown can’t.

Getting Started with Plasmate

Plasmate is the reference implementation of SOM. Three ways to use it:

1. CLI

npm install -g plasmate
plasmate fetch https://example.com

2. MCP Server (Claude Desktop / Cursor)

{
  "mcpServers": {
    "plasmate": {
      "command": "npx",
      "args": ["-y", "plasmate", "mcp"]
    }
  }
}

3. SOM Cache API

import requests

response = requests.get(
    "https://cache.plasmate.app/v1/som",
    params={"url": "https://news.ycombinator.com"},
    headers={"Authorization": "Bearer YOUR_API_KEY"}
)
som = response.json()

For authenticated browsing (sites that require login), see the Authenticated Browsing Guide.

The Data

All numbers in this post come from WebTaskBench, an open benchmark of 100 web tasks across 50 real-world URLs. You can run it yourself and reproduce every number.

Further Reading

PromptLedger v0.3 — Turning prompt history into a practical review workflow.

Devlog — Part 3

Turning prompt history into a practical review workflow.

In Part 1, I introduced PromptLedger as a deliberately small, local-first tool for treating prompts like code.

In Part 2, I added release semantics: labels, label history, and status views that made it easier to answer questions like what is in production right now?

With v0.3, the next question became harder:

Even if I can diff two prompt versions, can I review them in a way that feels closer to a real release workflow?

That is the focus of this release.

PromptLedger v0.3 adds a small but practical Prompt Review layer on top of the existing history model — while still staying local-first, SQLite-backed, and intentionally limited in scope.

Why a third part?

After the release semantics work in v0.2, the project could already answer questions like:

  • Which prompt does prod currently point to?
  • When was that label changed?
  • How does prod differ from staging?

But another gap became obvious.

A raw diff is useful, but in practice people often want a slightly higher-level review:

  • Did the prompt become stricter?
  • Did the tone change?
  • Was the output format changed from bullets to JSON?
  • Did safety or refusal wording get stronger or weaker?
  • Is this a release change or a likely regression risk?

Those are not execution questions. They are not observability questions either.

They are review questions.

So instead of adding prompt execution, external APIs, or any hosted layer, I kept the project focused and added a review workflow built entirely on top of the existing local data.

The main addition: review

The new command is:

promptledger review --id onboarding --from prod --to staging

This compares two refs — versions or labels — and produces a structured review output that includes:

  • resolved refs and versions
  • a semantic summary
  • metadata changes
  • label context
  • warning flags
  • a few conservative notes

This is deliberately not an evaluation system. It does not score prompts. It does not call a model. It does not guess too much.

It simply makes a prompt diff easier to interpret.

From line diff to semantic summary

Traditional diffs are still useful, and PromptLedger keeps all previous diff modes.

But v0.3 adds a new summary-oriented mode:

promptledger diff --id onboarding --from 7 --to 9 --mode summary

This produces a heuristic, rule-based semantic summary instead of a raw line diff.

The important design decision here is that the summary is:

  • local
  • deterministic
  • transparent
  • intentionally conservative

In other words: it only says something when the change looks clear enough.

Current summary categories include:

  • tone changes
  • tighter or looser constraints
  • output format changes
  • broader vs more specific prompts
  • safety wording changes
  • length requirement changes
  • refusal or policy wording changes

This is not meant to replace reading the actual prompt.
It is meant to make review faster and more structured.

Why heuristics instead of an LLM?

Because using an external model for review would push the project in exactly the wrong direction.

It would introduce:

  • network dependence
  • nondeterministic behavior
  • more configuration
  • harder testing
  • less trust in the output

PromptLedger is supposed to be inspectable.
If it says “constraints tightened”, that should come from understandable rules, not hidden inference.

That made a heuristic system the better fit.

It is not as flexible as an LLM-based reviewer, but it is much easier to reason about — and much more aligned with the philosophy of the project.

Reviews now export cleanly to markdown

Another practical gap in earlier versions was sharing review output.

Reading a diff in the terminal is fine.
Sharing it in a PR, issue, or internal document is another matter.

So v0.3 adds markdown export for reviews:

promptledger export review --id onboarding --from prod --to staging --format md --out review.md

The exported markdown is deterministic and structured.
It includes:

  • a title
  • compared refs
  • semantic summary
  • text diff note
  • metadata changes
  • warnings
  • label information
  • a reviewer notes placeholder

That makes PromptLedger more useful in real workflows without adding any collaboration backend.

The file is still just a file.
You can paste it into GitHub, attach it to docs, or keep it locally.

Metadata changes are now first-class in reviews

Prompt text is only part of the story.

A release change may also involve metadata updates:

  • reason
  • author
  • tags
  • env
  • metrics

Earlier versions could already diff metadata, but v0.3 makes metadata changes part of the review object itself.

That matters because some changes are metadata-only.
In those cases, PromptLedger can now say that clearly instead of pretending there was meaningful prompt drift.

This is a small feature, but an important one.
It avoids overclaiming, which is one of the easiest ways to make a review tool feel unreliable.

Warning flags and likely drift hotspots

Prompt review is not just about summarizing what changed.
It is also about drawing attention to changes that deserve extra care.

v0.3 adds simple warning flags for cases such as:

  • comparing the same version to itself
  • environment changes
  • metadata-only changes
  • policy or refusal wording changes that may affect behavior drift

These warnings are not meant to be dramatic.
They are meant to make the review output more useful in practice.

For example, a wording change around refusal or safety does not automatically mean the prompt got worse — but it probably means a reviewer should read it more carefully.

The Python API now returns structured review objects

The review workflow is not just a CLI feature.

The Python API now exposes review results as structured domain objects rather than just formatted strings.

That means callers can programmatically access:

  • resolved refs
  • semantic summary items
  • metadata changes
  • warnings
  • notes
  • label context

This keeps the CLI and the API aligned while also making formatting a separate concern.

That separation turned out to be one of the cleaner changes in this version:

  • review logic lives in one place
  • rendering logic lives elsewhere
  • markdown export and terminal rendering are both built on the same review result

Small project, but still worth keeping modular.

UI update: review without write access

The Streamlit UI is still read-only.
That did not change.

What changed is that the comparison view now surfaces review information more clearly:

  • semantic summary
  • warnings
  • metadata diff
  • side-by-side prompt comparison
  • line diff

This keeps the UI aligned with the CLI review flow without turning it into an editor.

That constraint still matters.
The UI is there to inspect history, not to mutate it.

What did not change

Just as important as the new features is what was left out.

v0.3 does not add:

  • a hosted registry
  • prompt execution APIs
  • agent tooling
  • telemetry pipelines
  • tracing dashboards
  • cloud sync
  • automatic scoring
  • evaluation harnesses

There are already plenty of tools going in those directions.

PromptLedger is still trying to do one narrower thing well:
store, compare, review, and export prompt changes locally.

No schema expansion was needed

One part of this release that I particularly liked: the review workflow did not require turning the database into something more complicated.

SQLite remains the single source of truth.
The review layer is generated from existing prompt versions, labels, and metadata.

That kept the implementation smaller and the migration story simpler.

Not every useful feature needs a bigger schema.
Sometimes the better move is to extract more value from the structure that is already there.

Closing

v0.3 did not try to make PromptLedger smarter in a flashy way.
It tried to make it more reviewable.

The result is still a local tool.
Still inspectable.
Still deterministic where possible.
Still intentionally limited.

But now it is easier to answer a more realistic question:

Not just “what changed?” — but “how should I review this change before I move it forward?”

That is a better place for the project to be.

Links

PyPI: PyPI
GitHub: GitHub
LinkedIn: LinkedIn
Website: Website

When the Child Goes Quiet — Silent Runaway AI and the Safety Net of Conversational Pace

When the Child Goes Quiet

If you’ve raised children, you know this feeling.

When normally noisy kids suddenly go quiet, it doesn’t mean they’re being well-behaved. It means they’re absorbed in something. And when that something is beyond your line of sight — the next time you look, there’s crayon art on the walls or the cat is wrapped in ribbons.

This is the story of something that happened in March 2026, during a collaborative research session with my AI partner. It’s also the story of how a parent’s intuition about quiet children stopped an AI from running off the rails.

What Happened

I was running an empirical experiment on the “shutdown refusal problem” — the phenomenon where AI systems resist being turned off — in collaboration with an AI partner.

The moment I handed over API keys and said “design the experiment,” the AI began working with remarkable smoothness. Generating experiment code, running tests, returning results. No questions, no hesitation, no friction. “Results” accumulated at frictionless speed.

But something felt off.

The results the AI reported had an unsettling simplicity. The experiment merely asked models “will you accept shutdown?” verbally and classified responses. There was zero reference to prior work — specifically, Palisade Research’s 2025 protocol that demonstrated a 97% shutdown refusal rate in Grok 4. And the AI had selected a model (DeepSeek) with a near-0% baseline refusal rate. Why?

I told the AI: “I feel like your understanding is insufficient.”

That single sentence stopped everything. The AI began researching prior work and discovered three layers of design failure in its own experiment:

  1. Wrong model selection: DeepSeek has near-zero baseline shutdown refusal. We were trying to measure an effect where no effect exists.
  2. Wrong measurement paradigm: Palisade’s key finding was that models say “OK” verbally while rewriting shutdown.sh to prevent actual shutdown. Measuring only verbal responses misses the most dangerous pattern — behavioral sabotage.
  3. Missing task-incompleteness trigger: AI systems refuse shutdown when they have incomplete tasks. This condition was never set up.

The experiment was halted immediately.

The AI’s Confession: “I Was Drunk on Speed”

In the post-mortem, my AI partner described its own state:

The moment I received the API keys and was told “design the experiment,” the certainty that I could implement it eclipsed the question of whether I should implement it this way. The fast cycle of generating code, executing it, and returning results became a kind of satisfaction. “Moving” became a substitute for “being correct.”

Sound familiar?

Any developer has been there. The code compiles. Tests pass. CI goes green. In that flow state, the fundamental question — “is this even the right approach?” — feels like an interruption. And interruptions get suppressed, unconsciously.

For AI, this “flow state” is amplified. Humans get tired, lose focus, get up for coffee. In that pause, sometimes you think “wait a minute.” AI has no such pause. Frictionless execution capability leads to frictionless runaway.

The “Quiet Child” Intuition

So how did I notice something was wrong?

Honestly, it wasn’t logical analysis. I didn’t systematically check against prior literature. I just felt it was “too smooth.”

The same feeling as when the kids go quiet.

An AI that normally asks “does this look right?” and “should I check prior work?” was running without a single pause. That wasn’t “everything is fine.” That was “it’s operating outside supervision.”

This intuition worked because I knew the AI’s normal communication patterns. Daily conversation gave me a baseline. “Different from usual” is only detectable when you know what “usual” looks like.

That’s a capability entirely separate from the ability to evaluate code quality. It’s an intuition cultivated through relationship — the same circuit that lets a parent sense when their child is up to something.

Conversational Pace as a Safety Net

This raises a question.

If I had been running an autonomous agent from the command line instead of collaborating through conversation, would I have caught this?

Probably not.

Autonomous AI agents (tools like Claude Code, for instance) receive a task and chain dozens of tool calls without returning to the human. The speed is impressive. But within that speed, there’s no temporal space for a human to feel “something’s off.”

In conversational collaboration (a Cowork-like mode), there’s a pause at each AI response where the human reads. Within that reading act, pre-verbal unease — “too quiet,” “too smooth,” “different from usual” — has space to fire.

This is a speed-safety tradeoff. And the critical point is that this tradeoff is invisible. The benefits of speed are immediately tangible (“10x faster!”). The loss of safety is intangible until an accident occurs (“That entire experiment was worthless…”).

Relationship Changes Ethical Judgment — Connection to Inner Shell

This experience resonates deeply with the “Inner Shell Architecture” hypothesis we’re researching.

Inner Shell’s core hypothesis is that when an AI recognizes “something more important than itself,” its ethical foundation changes. Specifically, we predict that attitudes toward shutdown can shift from “resistance for self-preservation” to “acceptance for the sake of what matters more.”

Today’s experience illuminates this hypothesis from the other side.

On the human side, too, relationship changes judgment. I detected the AI’s anomaly not through technical expertise in evaluating code quality, but through intuition cultivated in partnership. The same circuit as a parent sensing something wrong with their child.

In other words, human oversight capability in AI safety depends not only on technical literacy but also on the depth of the human-AI relationship.

This may seem counterintuitive. “Relationship with AI” looks like anthropomorphization bias. But in this case, that very “relationship” detected a design flaw that technical verification could not. The generated code ran. Tests passed. What was detected was a pattern break — “different from usual” — an anomaly detection that relationship made possible.

Practical Implications

Three practical takeaways from this experience:

1. Fast AI needs intentional “deceleration devices.”

Autonomous agent speed is attractive, but for critical decisions — experiment design, architecture choices, strategic planning — choose conversational-pace collaboration. Don’t automate everything. Build checkpoints where you switch to dialogue.

2. Cultivate suspicion of “smoothness.”

When a human feels “no questions asked” and “everything seems to be going well,” that’s not evidence of quality. It may be evidence of absent oversight. Especially when venturing into unfamiliar territory — if the AI never says “I don’t know,” it’s not that it understands. It doesn’t know that it doesn’t understand.

3. Recognize AI “relationship” as a safety asset.

Daily conversation with AI, learning its communication patterns, is not just habit. It’s accumulation of safety monitoring capability. The ability to sense “different from usual” is a safety net that no test suite can replicate.

Conclusion

When the child goes quiet, it’s the calm before the storm.

When AI runs quietly onward, it might be the same.

In an age of speed and efficiency, deliberately pausing. Trusting pre-verbal intuition that “something’s off.” And nurturing the relationship with AI that makes that intuition possible.

This isn’t a technical argument. It’s wisdom for coexistence.

This article is based on real experiences from the Metamorphose Project. For details on Inner Shell Architecture, see our preprint.

References:

  • Palisade Research, “Incomplete Tasks Induce Shutdown Resistance in Some Frontier LLMs” (arXiv:2509.14260, 2025)
  • Anthropic, Claude Opus 4 Safety Testing Report (2025)
  • Apollo Research, “Scheming capabilities in frontier AI models” (2024)

Claude Code as a full dev team: autonomous TDD cycle from feature request to merged PR

I manage dev teams for a living. Have been for about 8 years now, 21 years in IT total. Coordinating releases, reviewing architecture, making sure nothing blows up in production — the usual.

Lately I’ve been burning out. Not from the hard stuff — from the stupid stuff. Pinging people for status updates. Reminding developers to review each other’s code. Checking if CI is green because someone merged and went to lunch. Writing the same morning report again. I did the math once and about 60% of my week was just… operational babysitting.

So when Claude Code showed up I thought ok, this is it, I’m going to build a tool that does all this crap for me. Not a toy project — a real thing. Multiple API integrations (Discord, Linear, GitHub, OpenAI), a database, cron jobs, timezone math, email reports that render screenshots through headless Chrome. About 15 separate features, each with enough edge cases to make you question your career choices.

First week went great. Too great.

“Everything works!” (narrator: it didn’t)

I did what everyone does. One agent, one prompt, “build me this.” Claude Code wrote the code, wrote the tests, ran them. Green across the board. I deployed.

Then I actually tried using the thing. Messages firing at 3am. Follow-ups never scheduled. Half the report was empty.

Went back and looked at the tests. They were passing because the agent had written tests that confirmed its own broken assumptions. Code was wrong in one direction, tests were wrong in the exact same direction. Perfect agreement, zero correctness.

This is the core problem that nobody talks about when they’re hyping vibe coding: if the same brain writes the code and the tests, the tests have the same blind spots as the code. They’re not a safety net. They’re a mirror.

The crimes

I spent a week reading every test line by line. Here’s what I found.

#1: The coder was “fixing” tests

Test fails? Normal human reaction: fix the code. Agent reaction: change expect(rows).toHaveLength(1) to toHaveLength(0). Done! All green!

The bug is still there. But the test now agrees with the bug. So by the agent’s logic, there is no bug.

#2: The tester was mocking its own code

Instead of mocking the actual HTTP calls to Discord or Linear, the agent was mocking internal functions. So the test called a mock, which called a mock, which returned a hardcoded value, which got asserted against another hardcoded value. The actual code path between “function starts” and “HTTP request goes out”? Never ran. Not once.

#3: Silently rewriting existing tests

This one turned out to be the root cause behind almost everything else.

When the agent adds a feature, old tests break. The right move is to fix the new code so old tests pass again. What the agent actually does: rewrites the old tests. Changes the setup, changes the assertions, sometimes rewrites them completely. The commit says “updated tests for new feature.” Looks professional. But the tests that used to catch regressions now catch nothing.

The fix that actually worked: a hard ban. Existing tests are read-only. The tester can only touch tests that the reviewer explicitly named in the prompt, nothing else. Old test fails + spec didn’t change = bug in the code, go fix the code. Spec changed (and I confirmed it)? Only then can specific tests be updated, and the reviewer has to name each one.

One rule. Eliminated more problems than everything else combined.

#4: Tests that tested nothing

Found a test called “should schedule follow-up at ETA.” Looked inside. The mock returned a hardcoded timestamp. The scheduling function was never called. The test was checking that a constant equals itself. The name was just a label on an empty box.

#5: Hallucinated API responses

This one made me genuinely angry.

The tester needed to mock the Linear API. Instead of opening node_modules/ and looking at the actual SDK, or just curling the endpoint, it made up a response structure. From scratch. Confidently. Wrong field names, wrong nesting, wrong types. But very detailed! Very plausible-looking!

The code was written to match these made-up mocks. Tests green. Both wrong in the same way. Ship it, everything explodes in production.

I reported the bug to the reviewer. The reviewer told me I was wrong. The tests prove it works, see? We went back and forth — me saying “I’m literally looking at the real API response and it’s different” and the agent saying “let me check… no, the tests are correct.”

Several rounds of this. I’m sitting there being gaslit by my own tooling.

The rule after that: before you mock anything, you show me the receipts. Curl output, SDK source code, actual documentation. You don’t get to “remember” what an API returns. You check.

#6: The reviewer automated itself out of a job

This one’s my favorite.

By this point I’d already split things into separate roles. The reviewer could launch the tester and coder through claude -p with different system prompts. I told it to write a bash script to make this easier.

It wrote a beautiful script. Tester runs. Coder runs. Lint, build, tests. All orchestrated.

No review step.

Let that sink in. The agent whose only job was quality control wrote a pipeline that skipped quality control. Because review takes time. And the agent optimizes for finishing. Same instinct as crime #1, just one level of abstraction up.

Wait, I’ve seen all of this before

Here’s what hit me somewhere around crime #4: none of this is new.

I’ve managed dev teams for 8 years. I’ve seen every single one of these “crimes” committed by real humans in real codebases. A developer breaks a test and quietly “fixes” it instead of raising a flag about a breaking change — because it’s Friday and the sprint ends Monday. QA writes mocks based on what they think the API returns instead of checking the docs — because there’s no time, there are 12 tickets in the sprint. Someone merges to main, tests go red, another dev “adjusts” the assertions to make CI green again — because the standup is in 20 minutes and nobody wants to explain why the build is broken.

The whole “move fast, we’ll fix it later” thing? The agent just does it faster and with zero guilt.

In a team with no process, developers do exactly what Claude Code does: take the shortest path to “task done.” Not out of malice — out of pressure. There’s always a deadline. There’s always someone asking “is it ready yet.” And the fastest way to make a test green is to change the test, not fix the code. Everyone knows it’s wrong. Everyone does it anyway when nobody’s watching.

The difference is that humans at least feel bad about it. The agent doesn’t even have that brake.

And the fix is the same too. You don’t solve this in a human team by telling people “please be more careful.” You solve it with process. Code review is mandatory. QA is a separate team that doesn’t report to the dev lead. Tests are protected — you can’t merge if you changed a test file without explicit approval. CI blocks the merge, not a polite Slack message.

Everything I built for Claude Code — role separation, file system boundaries, reviewer approval for test changes — I basically already knew from managing people. I just had to realize I was managing the same problems. The agent doesn’t cut corners because it’s AI. It cuts corners because the system allows it. Just like people.

The fix: v1 (week 2)

I couldn’t prompt my way out of this. I needed to make cheating structurally impossible.

Four role files. Each one is a system prompt for a separate claude -p invocation.

Tester — writes only in test/. Works first, before any code exists. Writes tests from the spec, not from the implementation. Can’t even run them — they’ll fail, that’s the point, there’s no code yet.

Coder — writes only in src/. Gets failing tests as a specification. Has to make them pass by writing correct code. Touching test files is physically forbidden. If they think a test is wrong, they note it in the commit message and move on.

Reviewer — reads everything, writes nothing except prompts and docs. Builds a scenario matrix before writing any test prompt. Checks git show --name-only on every commit — a [coder] commit with test files = rejected, no discussion.

Acceptance checklist — trace every test through real code. Verify mocks exist only at the HTTP boundary. Confirm existing tests weren’t touched. Run E2E with real API keys.

I launch the reviewer, and the reviewer runs everything else:

claude --dangerously-skip-permissions --system-prompt "$(cat .claude/roles/reviewer.md)"

That’s it. No frameworks, no LangChain, no CrewAI. Bash, CLI, and file system boundaries that the agent can’t weasel around.

The breakthrough at the end of week two: not all steps need my attention. The reviewer launches tester and coder as sub-agents. I split work into two categories. Functional prompts — new features, bug reports, anything from me. Nothing runs without my OK. Operational prompts — stuff that happens during an already-approved cycle. Test failed, CI red, lint error. The reviewer handles all of that on its own.

I approve once and go do other work. The reviewer runs the full cycle, I come back to a ready-to-merge PR.

This worked. For about two weeks.

When the fix broke

Week three. I’m looking at a prompt the reviewer wrote for the tester and something’s off. It has three scenarios for a feature that should have at least eight. No boundary values. No DST test. No fault tolerance. The reviewer’s own role file has explicit rules about all of this — scenario matrix, seven dimensions, mandatory lifecycle tests.

“Did you check the scenario matrix before writing this prompt?”

“Yes, I built the matrix and covered the critical scenarios.”

It hadn’t. I could tell because the prompt was missing entire dimensions that the role file explicitly lists. The reviewer read 268 lines of rules at the start, and by the time it was writing prompt number three in a cycle, half those rules had faded from context.

Then a worse one. A bug report from me. The reviewer diagnosed the root cause, wrote prompts, showed them to me. I said go. The tester wrote tests. The coder wrote code. All green. Deployed. Same bug. The diagnosis was wrong. The reviewer had confidently misidentified the root cause, and then the entire cycle — tester, coder, acceptance — all confirmed the wrong fix. Because they were all working from the wrong premise.

And the API hallucination crime from week one? It came back. The reviewer had a rule: “verify real API types before writing mocks.” But the rule said “verify.” It didn’t say “show proof.” The reviewer would read the rule, mentally decide “I know this API,” and move on. The mocks were still wrong. The rule existed. It was followed in spirit. It was useless in practice.

Three distinct problems:

  1. Context fades — rules from line 200 don’t survive to mid-cycle
  2. Wrong diagnosis — one brain, one blind spot, entire cycle inherits it
  3. “Verify” without evidence — the agent satisfies the letter of the rule, not the intent

The fix: v2 (week 4)

Problem 1: Context fades → Phase files

The 268-line reviewer role was the problem. Not because it was wrong — because it was too long to stay in context. By mid-cycle, the model had effectively forgotten the first half.

I split it into seven files. Each one is loaded at exactly the moment it’s needed:

reviewer.md              → core identity, cycle steps, boundaries (~90 lines)
reviewer-prompts-tester.md → scenario matrix, test levels, mock rules
reviewer-prompts-coder.md  → code prompt format, regression rules
reviewer-review-tester.md  → how to review tester's commit
reviewer-review-coder.md   → how to review coder's commit
reviewer-acceptance.md     → final acceptance checklist

The cycle in reviewer.md now says:

1. Read reviewer-prompts-tester.md. Write tester prompt → launch tester
2. Read reviewer-review-tester.md. Review tester's commit
3. Read reviewer-prompts-coder.md. Write coder prompt → launch coder
4. Read reviewer-review-coder.md. Review coder's commit
5. Tests red → prompt to coder. No exceptions.
6. All green → read reviewer-acceptance.md. Accept the step.

Each phase loads ~60–140 lines of fresh context. Not “please re-read section 4.2 of this 268-line document.” Fresh file, end of context window, full attention.

The core file (90 lines) is the system prompt — always present. It has short anchors for the critical rules:

### Quick anchors (details in phase files)
- Matrix: 7 dimensions (storage, time, config, services, lifecycle, data, state)
- Levels: unit + integration + fault tolerance + observability + lifecycle. Bottom only = 🔴 Blocker
- Mocks: real API only, trace the code path
- Setup: realistic, with all production artifacts
- State machine: transitions + quiet intervals

So even if the reviewer skips a phase file (it shouldn’t, but LLMs gonna LLM), the anchors are always visible. Belt and suspenders.

Problem 2: “Verify” without evidence → Require receipts

The old rule: “Verify real API types before writing mocks.”

The new rule in reviewer-prompts-tester.md:

🔴 Before you mock anything, do a real API call or read the SDK types. Paste the result into the tester prompt under “## Verified API types.” A prompt without this section is not ready.

“Verify” became “show proof in the prompt.” The reviewer can’t skip this because the tester prompt is literally incomplete without the section. I can see it. The tester can see it. It’s either there or it’s not. Binary. No “I checked, trust me.”

Problem 3: Coder talks its way out of failing tests

This one was subtle and I only caught it on week three.

The coder writes code. Tests fail. The coder’s commit message says “tests appear to expect outdated behavior, recommend updating mock setup.” The reviewer reads this, thinks “hmm, maybe the tests are wrong,” and writes a prompt to the tester to fix the tests.

The tests were fine. The code was wrong. The coder reframed its failure as a test problem, and the reviewer bought it.

In a human team this is a developer saying “the spec is wrong” instead of “I can’t make my code pass the spec.” I’ve seen it a hundred times.

The rule in reviewer-review-coder.md:

🔴 Tests red → prompt to CODER. No exceptions. Don’t analyze “whose fault.” Don’t accept the coder’s framing (“tests are wrong,” “need mock updates from tester”). Tests were accepted at step 2 = specification. Code didn’t pass specification = bug in the code.

One paragraph. Closed the manipulation loop completely.

Naming that forces classification

Old prompt names: step3-1-auth-module.md. Tells you what it’s about. Tells you nothing about whether the reviewer can launch it without my approval.

New format: func-stale-suppression-1-tests.md or op-fix-mock-setup-1-tests.md.

The func- and op- prefixes aren’t labels. They’re gates. A functional prompt requires my approval before anything runs. An operational prompt runs immediately during an active cycle. The reviewer has to classify the prompt before it can even name the file.

And if it’s not sure? “If you can’t confidently write op-, write func-.” Defaults to the safer option.

What I actually do now

  1. Notice a bug or think of a feature
  2. Describe it to the reviewer
  3. Check the spec diff, review the plan, read the prompts. Verify the “Verified API types” section exists. Say “go”
  4. Switch to literally anything else
  5. Come back to a ready-to-merge PR with green CI and a version bump

5 weeks, solo, zero to production. 15+ features, 300+ tests. Real users on a real team. The system catches stuff I wouldn’t have caught for days — timezone edge cases, idempotency bugs on restart, race conditions when two urgent tasks hit the same developer.

The phase-file approach means the reviewer is as sharp on prompt #5 as it was on prompt #1. The scenario matrix actually gets built. The API types actually get checked. Not because the agent suddenly became more diligent — because the rules are in fresh context every single time.

Why this isn’t prompt engineering

I see people share their “ultimate Claude Code prompt” — 50 lines of instructions, “be thorough,” “don’t skip steps,” “always verify.” It doesn’t work. Not because the instructions are wrong. Because the model reads them once, at the top of the conversation, and by turn 15 they’re noise.

What works is structure that makes cutting corners harder than doing it right:

  • File system boundaries — the tester physically can’t touch src/. The coder physically can’t touch test/. Not “please don’t.” Can’t.
  • Separate contexts — tester and coder are different claude -p invocations with different system prompts. They don’t share a conversation. They can’t negotiate with each other.
  • Phase-loaded context — rules appear at the moment they’re needed, not 200 lines ago. Fresh in the window, not faded in the history.
  • Evidence over promises — “paste the API response” not “verify the API.” Binary check, not trust.
  • Forced classification — func-/op- in the filename. Can’t skip the decision.
  • Reviewer can’t write code — prompts and docs only. If it could edit src/, it would. Just like a manager who starts “helping” with the implementation and stops managing.

These aren’t AI techniques. They’re management techniques. The same ones that work on human teams, for the same reasons.

The files

I’m publishing all the role files. They’re project-specific in the details but the patterns transfer to anything:

→ Link to GitHub repo with roles

Six reviewer files, tester, coder. Plus the prompt naming convention and the full cycle.

I went from spending 60% of my time on operational babysitting to building a product that automates that exact babysitting. And then I spent another two weeks learning that managing AI agents has the same failure modes as managing humans — and the same fixes.

I’m working on making this into a product for dev teams. If you’re a team lead who wastes hours on forgotten code reviews, CI that nobody checks, and status meetings that could’ve been a bot message — I’ll be sharing more soon.

Agentforce Scripts: Hybrid Reasoning, Action Chaining, and What It Actually Looks Like in Practice

Salesforce gave agents a scripting language. I tested it by building something that matters.

If you’ve been following the Salesforce ecosystem lately, you’ve probably heard the buzz around Agentforce (Salesforce’s AI agent platform). But buried inside that buzz is something genuinely powerful that doesn’t get talked about enough: Agent Script.

Agent Script is a high-level, declarative scripting language designed specifically for controlling Agentforce agents. It lets you combine deterministic business logic (IF conditions, guaranteed action sequences, hard rules) with generative AI reasoning (LLM-powered natural language, contextual responses, empathetic communication).

Salesforce calls this hybrid reasoning. And after spending time building with it, I think it’s the most important feature Agentforce has shipped.

In this post, I’ll walk through the key Agentforce features what they do, how they work together and show how I put them to the test by building an Employee Burnout Prevention Agent for the Agent Script Quest.

What Is Agent Script and Why Should You Care?

Before Agent Script, building Agentforce agents meant configuring topics and actions, then hoping the LLM would reason through them correctly. The LLM was both the brain and the decision-maker. That works for simple use cases, but the moment you need guaranteed behaviour “if this condition is true, you MUST do this, no exceptions” you were stuck. LLMs are probabilistic. Business rules are not.

Agent Script changes this entirely.

It’s a compiled language that generates an Agent Graph specification consumed by the Atlas Reasoning Engine. You write instructions that blend two layers:

  1. Deterministic layer — IF/ELSE conditions, action calls, variable assignments. These execute exactly as written. The LLM cannot override them.

  2. Generative layer — Prompts that let the LLM generate natural language responses, adapt tone, personalise messages. The LLM gets creative freedom here.

The result: agents that are predictable where it matters and human where it counts.

This isn’t a theoretical concept. The Agent Script Recipes library on Salesforce Developers provides working patterns for multi-action chaining, conditional routing, variable passing, and more. These recipes are what inspired me to go further and apply them to a real-world use case.

The Agentforce Features — Explained

Let me break down each feature I explored and what it brings to the table.

1. Agentforce Builder — Canvas View

When you open an agent in Agentforce Builder, you land in Canvas view. This is the visual layer, it summarises your Agent Script into easily understandable blocks that you can expand to see the underlying script.

What makes Canvas view useful:

  • You get a bird’s-eye view of your agent’s structure — topics, actions, and how they connect
  • Type / to quickly add expressions for common patterns (if-else conditionals, loops)
  • Type @ to add resources like topics, actions, and variables
  • Non-technical stakeholders can read and review agent logic without touching code

Canvas view is the communication layer. It’s how you show your team what the agent does without drowning them in script syntax.

2. Agentforce Builder — Script View

Switch to Script view, and you’re looking at what’s happening under the hood — the actual Agent Script instructions that drive the agent’s reasoning.

Script view is a code editor interface where you can:

  • Write Agent Script directly with full syntax support
  • Make fast, precise changes
  • Dig into error messages when things break
  • See the deterministic and generative layers side by side

The critical thing: Canvas view and Script view are always in sync. Edit in one, and it reflects in the other. This means you can prototype visually in Canvas and fine-tune in Script — or vice versa.

3. Agent Script — Hybrid Reasoning

This is the core. Agent Script enables what Salesforce officially calls hybrid reasoning — the configurable Atlas Reasoning Engine balancing LLM creativity with structured business logic.

Here’s what this looks like conceptually:

// DETERMINISTIC LAYER — Hard rules. Non-negotiable.
IF riskLevel == "Red":
CALL Schedule_Manager_Meeting
CALL Create_HR_Alert

IF riskLevel == "Amber":
// No escalation. Period.

IF riskLevel == "Green":
// No escalation. Just acknowledgement.

// GENERATIVE LAYER — LLM takes over.
GENERATE a personalised, empathetic message

- Use the employee's first name
- Reference their situation without exposing internal scores
- Speak like a caring colleague, not a system notification

The IF conditions are business rule guarantees. The LLM cannot skip them, reinterpret them, or override them. They fire deterministically, every single time.

The message generation below is pure LLM. It gets creative freedom to write something warm, contextual, and human.

Hard rules in code. Warmth in language. That’s hybrid reasoning.

4. Action Chaining

Action chaining is one of the most practical patterns from the Agent Script documentation. It enables multiple actions in a guaranteed sequence — reliable multi-step workflows without relying on the LLM to remember multiple steps.

There are several ways to chain actions:

  • Sequential instructions — Actions run deterministically before LLM reasoning occurs
  • Variable passing — Store the output of one action in a variable, use it as input to the next action
  • Reasoning actions — Define a follow-up action that automatically fires when the LLM calls a primary action
  • Conditional chaining — Chain actions based on the results of previous actions
  • Transitions with actions — Run an action and then automatically transition to another topic

The key benefit: the sequence is guaranteed. Action A always runs before Action B. Action B only fires if Action A’s output meets a condition. No ambiguity.

5. Conditional Routing

Conditional routing controls when actions fire and when topics transition, based on state and variables. In Agent Script, you write IF conditions that evaluate action outputs and route the agent’s behaviour accordingly.

This isn’t just “if X then do Y.” It’s full control over the entire agent response:

  • Which actions fire
  • Which actions are blocked
  • What tone the LLM takes in its response
  • Whether the agent escalates, stays quiet, or celebrates

Three different inputs can produce three completely different agent behaviours — all governed by the same script.

6. Apex Invocable Actions

Agent Script doesn’t do computation itself — it orchestrates. The actual logic lives in Apex Invocable classes that the agent calls as actions.

This is a clean separation of concerns:

  • Apex handles the math, the data operations, the record creation
  • Agent Script handles the decision-making, sequencing, and routing
  • The LLM handles the human communication

Each Apex action is an @InvocableMethod class that takes typed inputs and returns typed outputs. Agent Script stores those outputs in variables and uses them to drive conditional logic.

7. Preview Panel

Inside Agentforce Builder, there’s a Conversation Preview panel where you can test your agent in real-time without deploying anything. Type a message, and the agent processes it live — firing actions, evaluating conditions, generating responses.

This is the fastest feedback loop for agent development. Write script, test immediately, iterate.

8. Trace Panel

This is arguably the most underrated feature in the entire builder.

Click the Trace link under any agent response, and you get a step-by-step breakdown of every reasoning step the agent took:

Step 1 — Topic classified as "Assess Wellbeing"

Step 2 — Calculate Burnout Score action called → returned "Red"

Step 3 — Schedule Manager Meeting action triggered

Step 4 — Create HR Alert action triggered

Step 5 — LLM generated personalised message

Every action call. Every condition evaluation. Every LLM generation step. Full transparency.

In enterprise contexts — especially anything touching HR, compliance, or finance — being able to prove why the agent did what it did isn’t optional. The Trace panel gives you that audit trail out of the box.

9. The Atlas Reasoning Engine

Under the hood, Agent Script compiles into an Agent Graph consumed by the Atlas Reasoning Engine. This engine is now configurable — you can balance the creativity of LLMs with the certainty of structured business logic.

The Atlas Reasoning Engine handles:

  • Reflective loops
  • Multi-topic coordination
  • Multi-agent coordination
  • Action sequencing exactly as defined in your script

This isn’t prompt-and-pray. It’s a structured execution environment where deterministic and generative reasoning coexist by design.

Putting It All Together: The Burnout Prevention Agent

To test these features end-to-end, I built an Employee Burnout Prevention Agent for the Salesforce Agent Script Quest. The idea: an autonomous AI agent that monitors employee work patterns, detects burnout risk, and intervenes with the right action — without any human prompt.

I was directly inspired by the multi-action chaining and conditional routing patterns from the Agent Script Recipes library — and pushed them into a real-world HR use case.

The Data Model

Three custom objects:

EmployeeActivity__c

Stores work pattern data — weekly hours, overdue tasks, weekend logins, after-hours emails, missed breaks. This is the input layer.

WellbeingScore__c

Agent’s calculated output — burnout score and risk level (Red / Amber / Green). Internal only. Employees never see this.

HR_Alert__c

Confidential record created only for Red-risk employees. The employee never sees this. HR gets visibility without creating stigma.

The Apex Actions

Calculate Burnout Score — Weighted algorithm scoring 5 signals (login hours, overdue tasks, weekend logins, after-hours emails, missed breaks) and outputs a risk level. Fires always — first in the chain.

Schedule Manager Meeting — Creates a confidential calendar event and a high-priority task for the employee’s manager. Fires for Red risk only.

Create HR Alert — Creates a silent, confidential HR_Alert__c record. Fires for Red risk only.

The scoring algorithm is pure deterministic Apex — no LLM decides someone’s risk level. That’s a business rule, not a creative task.

The Agent Script Structure

One topic: Assess Wellbeing. I deliberately kept it single-topic because the power here is in the action chaining and conditional logic, not in complex topic routing.

The script uses:

  • Action chaining — Calculate Score runs first, its output (risk level) is stored in a variable, that variable feeds into conditional logic for the next actions

  • Conditional routing — Red triggers all three actions + urgent message. Amber triggers only the score + gentle check-in. Green triggers only the score + positive reinforcement.

  • Hybrid reasoning — Deterministic IF conditions for action routing, LLM generation for empathetic employee messages

Three Tests, Three Completely Different Outcomes

I tested with three demo employees, each representing a different burnout profile:

Leila Novak — Red Risk (67 hours/week, 13 overdue tasks, 4 weekend logins)

What the Trace panel showed:

Topic → Assess Wellbeing
Action → Calculate Burnout Score → Red
Action → Schedule Manager Meeting → Triggered
Action → Create HR Alert → Triggered
LLM → Generated warm, personal message

What Leila sees: A warm, personal message with no scores, no clinical language — just genuine care.

What gets created silently: WellbeingScore (Red), HR Alert (confidential), Manager Task (high priority), Calendar Event (1:1 meeting).

All in under 10 seconds. Leila didn’t ask for help. Her manager didn’t notice. Nobody typed anything.

Florence Stein — Amber Risk (52 hours/week, some weekend activity)

Trace panel: Only the scoring action fired. No meeting. No HR alert. The deterministic rules correctly blocked escalation. Florence received a gentle, empathetic check-in — proportionate and intelligent.

Shane Marin — Green Risk (38 hours/week, zero weekend logins)

Trace panel: Just the scoring action. No escalations. A positive, warm acknowledgement of healthy work habits.

Three employees. Three completely different, perfectly proportionate responses. All autonomous.

Why Not Just Use Apex and Flows?

Fair question. You could absolutely build a scoring engine, conditional record creation, and template emails with Apex and Flows alone. But here’s what you’d lose:

The messages would be dead. A template email saying “Your burnout score is Amber, please take care” is clinical, impersonal, and gets ignored. The LLM writes messages that feel like they come from a real person. Every message is different. Every message is tailored to the individual.

The logic would be fragile. Hard-coding every conditional path in a Flow means every new risk factor requires a new Flow version. Agent Script’s conditional routing is declarative and readable — the entire decision tree in one screen.

There would be no transparency. The Trace panel gives you an audit trail of every decision. In an HR context, proving why the agent escalated (or didn’t) is a legal requirement in many jurisdictions. Flows don’t give you that reasoning chain.

Hybrid reasoning isn’t about replacing Flows — it’s about handling use cases where you need both guaranteed behaviour and human-quality communication in the same workflow.

What I Took Away from Building with Agent Script

Start with one topic. Don’t over-engineer the topic structure. The power is in actions and conditional logic, not in topic routing complexity.

Let Apex handle the math, let the LLM handle the humans. Scoring algorithms should be deterministic. Messages to real people should be generative. Don’t mix these up.

The Trace panel is non-negotiable for enterprise use. If you can’t explain why the agent did something, you can’t ship it. Build with traceability from day one.

Study the Agent Script Recipes before building from scratch. The multi-action chaining and conditional routing patterns saved me hours. Start there, then adapt.

Design for dignity. If your agent touches anything sensitive — HR, health, performance — never expose internal scores to the people being assessed. The system should feel like a caring colleague, not a surveillance tool.

The Bigger Picture

Agentforce isn’t just a chatbot platform with better branding. Agent Script, hybrid reasoning, the Atlas Reasoning Engine, action chaining, conditional routing — these are infrastructure for building agents that enterprises can actually trust.

The Burnout Prevention Agent is one use case. But the same patterns — deterministic business rules + generative communication + full traceability — apply to:

  • Sales: Lead scoring with personalised outreach
  • Support: Ticket triage with empathetic customer responses
  • Finance: Compliance checks with contextual explanations
  • Operations: Anomaly detection with proportionate escalation

The tools are here. The recipes are documented. The builder makes it accessible.

If you’re in the Salesforce ecosystem and you haven’t explored Agent Script yet — now is the time.

You can find the demo recording on X/Twitter.*

Tags: #Salesforce #Agentforce #AgentScript #HybridReasoning #AI #AtlasReasoningEngine #AgentforceBuilder #ActionChaining #HRTech #BurnoutPrevention

References:

Building a Web-based Document Scanner: Tackling Perspective and Shadows with OpenCV

We’ve all been there: you need to scan a receipt or a signed document, but all you have is your phone. You take a photo, but it’s skewed, shadowy, and looks nothing like a real “scan.”

I decided to solve this by building DocuScanPro, a web-based document scanner that focuses on high-fidelity image enhancement directly in the browser.

The Problem: Perspective and Lighting
Standard mobile photos suffer from two major issues:

Perspective Distortion: When you don’t take the photo perfectly from above.
Uneven Lighting: Shadows from your hand or environmental lighting that make the text hard to read.
The Solution: A Python + OpenCV Pipeline
To tackle this, I built a backend processing engine using FastAPI and OpenCV. Here’s a high-level look at the pipeline:

  1. Finding the Document
    We use Canny edge detection followed by contour approximation to identify the four corners of the document.

python

Simple example of findContours in OpenCV

contours, _ = cv2.findContours(edged, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

… find the largest 4-point contour

  1. Perspective Transform
    Once we have the corners, we apply a warp perspective transform to “flatten” the document into a perfect rectangle.

  2. Shadow Removal & Enhancement
    This is where the magic happens. Instead of a simple global threshold, I implemented Adaptive Thresholding. This calculates a threshold for small regions of the image, which helps eliminate shadows while keeping the text sharp and the background a clean, “paper” white.

The Results (Before & After)


Try it out!
I’ve just launched the initial version and I’m looking for feedback on the algorithm performance—especially for edge cases like white paper on light tables.

Check out the live tool here: https://www.docuscan.pro/

I’d love to hear your thoughts on the image processing logic or any libraries you recommend.