Your Encrypted Backups Are Slow Because Encryption Isn’t the Bottleneck

If you encrypt files before pushing them to backup storage, you’ve probably assumed the encryption step is what makes it slow. That’s what I assumed too. Then I looked at the numbers. On any modern x86 chip with AES-NI, AES-256-GCM runs at 4-8 GB/s on a single core. ChaCha20-Poly1305 isn’t far behind. The CPU is not the problem. The problem is that your encryption tool reads a chunk of data, encrypts it, writes it out, then reads the next chunk. It’s serial. The disk sits idle while the CPU works, and the CPU sits idle while the disk works.

One person decided to fix that by applying the same async I/O technique that powers modern databases to file encryption. The result hits GB/s throughput on commodity NVMe hardware, and the whole thing is about 900 lines of Rust.

What Is Concryptor?

Concryptor is a multi-threaded AEAD file encryption CLI built by FrogSnot. It encrypts and decrypts files using AES-256-GCM or ChaCha20-Poly1305 with Argon2id key derivation, and it does it fast by overlapping disk I/O with CPU crypto using Linux’s io_uring interface. It handles single files and directories (packed via tar), runs entirely in the terminal, and installs with cargo install concryptor.

73 stars. One month of focused development. A six-file core with 67 tests. It deserves more.

The Snapshot

Project Concryptor
Stars 73
Maintainer Solo (FrogSnot)
Code health Clean architecture, 67 tests, clippy and fmt now enforced in CI
Docs Excellent README with honest perf analysis and full format spec
Contributor UX Fresh templates and CI, small codebase, easy to navigate
Worth using Not yet for production (author’s own disclaimer), but the architecture is real

Under the Hood

The centerpiece is a triple-buffered io_uring pipeline in engine.rs. The idea is simple: keep three sets of buffers rotating through three stages. While buffer A’s encrypted contents are being written to disk by the kernel, buffer B is being encrypted in parallel by Rayon worker threads, and buffer C’s plaintext is being read from disk. Every component stays busy. Nothing waits.

The implementation is tighter than you’d expect from a month-old project. Each io_uring submission queue entry carries bit-packed metadata in its user_data field: the low two bits identify which buffer slot, bit 2 flags read vs. write, and the upper bits store the expected byte count for short-I/O detection. When completion queue entries come back, the pipeline routes them to per-slot counters without any hash lookups or allocations. The whole loop runs num_batches + 2 iterations to let the pipeline drain cleanly at the end.

The file format is designed around O_DIRECT. Every encrypted chunk is padded to a 4 KiB boundary. The header is exactly 4096 bytes (52 bytes of data plus KDF parameters plus zero padding). Buffers are allocated with explicit 4096-byte alignment via std::alloc. This lets Concryptor bypass the kernel’s page cache entirely, talking directly to NVMe storage via DMA. It’s the same technique databases use to avoid double-buffering, and it’s a big part of why the throughput numbers are real.

The security model is more careful than I expected from a solo hobby project. The full 4 KiB header is included as associated data in every chunk’s AEAD tag, so modifying any header byte invalidates all chunks. There’s a TLS 1.3-style nonce derivation scheme where each chunk’s nonce is the base nonce XOR’d with the chunk index, preventing nonce reuse without coordination. A final-chunk flag in the AAD prevents truncation and append attacks. The 4032 reserved bytes in the header are authenticated too, so you can’t smuggle data into them. The test suite covers chunk swapping, truncation (two variants), header field manipulation, reserved byte tampering, KDF parameter tampering, and cipher mismatch. These aren’t afterthought tests. Someone thought about the threat model.

What’s rough? The project is Linux-only. io_uring doesn’t exist on macOS or Windows, and there’s no fallback backend. If you try to build it on a Mac you’ll get errors that don’t explain why. The README is upfront about the experimental status, which is honest and appreciated, but it does mean you shouldn’t point this at anything you can’t afford to lose yet. The rand dependency is still on 0.8 (0.10 is current), and until recently clippy warnings and formatting drift had been accumulating unchecked. None of these are architectural problems. They’re the kind of rough edges you get when one person is focused on making the core work first.

The Contribution

CONTRIBUTING.md asks you to run clippy and cargo fmt before submitting, but CI only ran cargo test. No enforcement. The result was predictable: 7 clippy warnings had accumulated across engine.rs and header.rs, and formatting had drifted in almost every source file.

I fixed all seven lints. Three were manual div_ceil reimplementations (the (a + b - 1) / b pattern that Rust now has a method for), one was a min/max chain that should have been .clamp(), one was a manual range check, and two were too_many_arguments warnings on internal pipeline functions where every parameter is essential and restructuring would just add noise. I also wired up KdfParams::DEFAULT via struct update syntax to eliminate a dead-code warning, ran cargo fmt --all, and added clippy and fmt checks to the CI workflow so they stay clean going forward.

Getting into the codebase was straightforward. Six files, clear responsibilities: engine.rs handles the pipeline, crypto.rs handles primitives, header.rs handles the format, archive.rs handles tar packing. The code is dense but not clever. You can follow the pipeline loop without needing to hold too much in your head at once. I had the PR ready in under an hour.

PR #10 is open as of this writing.

The Verdict

Concryptor is for people who encrypt files regularly and want it to be fast. If you’re backing up to cloud storage, encrypting disk images, or just moving sensitive data between machines, the throughput difference between a serial encryption tool and a pipelined one is real. On NVMe, it’s the difference between saturating your drive and leaving most of its bandwidth on the table.

The project is early. One maintainer, one month old, Linux-only, self-labeled experimental. It could stall. But the commit history tells a story of deliberate progression: the initial mmap approach was replaced with io_uring in the same day, security hardening followed within a week, the format was upgraded to v4 with full header authentication, and directory support landed before the first month was out. That’s not hobby-project pacing. That’s someone building something they intend to use.

What would push Concryptor to the next level? A fallback I/O backend for macOS and Windows would be the single biggest improvement. Even a plain pread/pwrite loop, slower than io_uring but functional, would open the project to most Rust developers who want to try it. Stdin/stdout streaming for pipe composability would help too. And the rand 0.8 to 0.10 migration is a real breaking change that Dependabot can’t auto-fix. That’s a contribution waiting to happen.

Go Look At This

If you care about I/O performance, encryption, or io_uring, Concryptor is worth reading. The codebase is small enough to understand in an afternoon, and the pipeline implementation is one of the cleaner io_uring examples I’ve seen in the wild.

Star the repo. Try encrypting a large file and watch the throughput. If you want to contribute, the rand 0.8 to 0.10 migration is sitting there waiting for someone to pick it up.

This is Review Bomb #11, a series where I find under-the-radar projects on GitHub, read the code, contribute something, and write it up. If you know a project that deserves more eyeballs, drop it in the comments.

This post was originally published at wshoffner.dev/blog. If you liked it, the Review Bomb series lives there too.

$fillable Has No Context: Why Mass Assignment Breaks Down at Scale

Mass assignment in Laravel is one of those things that feels like magic at first.
You see it in every tutorial: just toss your validated data into Model::create($data) or $model->update($request->validated()), set up your $fillable, and you’re off to the races. For quick projects? It works. But when your app starts to get bigger, what once felt convenient can start to cause real trouble.

The Usual Approach

Let’s be real-most controllers start out pretty much like this:

// OrderController.php
public function store(OrderStoreRequest $request): OrderResource
{
    $order = Order::query()->create($request->validated());

    return OrderResource::make($order);
}

It’s clean, it’s simple, and it just works. You throw whatever new fields you need into $fillable, and data slides right in.

But your Order model slowly grows. You add billing fields, delivery options, status flags, internal data. Suddenly $fillable looks like this:

protected $fillable = [
    'user_id', 'manager_id', 'number',
    'price_type_id', 'contract_code', 'currency_id',
    'address_id', 'status_id', 'direction_id', 'direction_type_id',
    'deliver_together', 'is_forced_processing', 'price', 'comment',
    'courier_date', 'courier_time', 'phone', 'first_name', 'last_name',
    'ip', 'user_agent', 'geo_location', 'finance_condition_data', 'is_from_api',
];

Twenty-four fields. Now ask yourself: what does Order::create($request->validated()) actually write to the database when called from the customer API? What about from the admin panel? From an internal backend action?

You don’t know without digging into the request class, the validation rules, and then mentally subtracting fields that don’t come from that particular endpoint. That’s the real problem.

What Goes Wrong in Practice

You lose visibility fast

When you have three different endpoints that create an order – customer-facing, frontend panel, admin – each sets different fields. With mass assignment, the controller tells you nothing. You have to trace the full chain: controller → FormRequest → $fillable → model. And even then, you’re guessing which fields actually come in from each context.

A new developer joins, gets a ticket “order is created with wrong status”, searches for where status_id is set on Order… and finds nothing obvious. Because it comes in somewhere inside $request->validated().

The real problem: $fillable has no context

Here’s the deeper issue. It’s not really about how many fields are in $fillable. It’s about what those fields mean and who is allowed to set them.

Look at the Order model again:

protected $fillable = [
    'is_forced_processing', // only admins
    'is_from_api',          // only customer API
    'manager_id',           // only internal, backend logic
    'status_id',            // should only be set internally
    'price',                // computed automatically
    // ...20 more
];

$fillable is a flat list. It has no idea who is calling Order::create(). It doesn’t know if it’s the admin panel, the customer API, or an internal action. Every field in that list is equally “allowed” for everyone.

So what do you put in $fillable? If you add is_forced_processing – it becomes fair game for any endpoint that calls Order::create($data). If you leave it out – your admin action has to use fill() + save() or Query::update() to bypass the guard. Either way, you’re working around the model instead of working with it.

$fillable protects against fields that are not in the list. It doesn’t protect against fields that are in the list but shouldn’t be settable from a specific context. That’s a different kind of protection – and it belongs at the endpoint level, not the model level.

What I Use Instead

The pattern that actually works: DTO + Action + explicit field mapping.

The controller converts the request to a typed DTO:

// OrderController.php (Frontend)
public function store(
    OrderStoreRequest $request,
    OrderCreateAction $orderCreateAction,
): OrderResource {
    $orderCreateDTO = OrderCreateDTO::fromRequest($request);
    $order = $orderCreateAction->handle($orderCreateDTO, $user->id);

    return OrderResource::make($order);
}

The DTO maps exactly what this endpoint cares about:

final class OrderCreateDTO
{
    public string $firstName;
    public string $lastName;
    public string $phone;
    public bool $deliverTogether;
    public ?int $directionId = null;
    public ?string $comment = null;
    public ?string $ip = null;

    public static function fromRequest(OrderStoreRequest $request): self
    {
        $self = new self();
        $self->firstName = $request->safe()->input('first_name');
        $self->lastName = $request->safe()->input('last_name');
        $self->phone = $request->safe()->input('phone');
        // ... etc, explicitly mapping
        $self->ip = $request->ip(); // this isn't user input :)
        return $self;
    }
}

The Action uses the DTO and writes exactly what it needs to:

final class OrderCreateAction implements Actionable
{
    public function handle(OrderCreateDTO $dto, int $userId): Order
    {
        // ... business logic: cart check, conditions, geo IP ...

        $order = Order::query()->create([
            'user_id' => $userId,        // from auth
            'status_id' => $statusEnum->value, // computed
            'price_type_id' => $userPriceType->id, // from profile
            'first_name' => $dto->firstName,
            'last_name' => $dto->lastName,
            'phone' => $dto->phone,
            'ip' => $dto->ip,
            // ...
        ]);

        return $order;
    }
}

Now it’s obvious: user_id comes from auth, status_id is computed internally, first_name comes from the DTO. You don’t have to trace three files to understand what the endpoint writes.

And the admin OrderCreateAction is a separate class with its own DTO – it sets different fields, without any chance of leaking between contexts.

The “Just Disable It” Way

Some devs go a different route: skip $fillable entirely and disable mass assignment protection globally. Nuno Maduro included this as an opt-in feature in his essentials package – the idea is that $fillable gives a false sense of security and the real protection should happen at the validation layer, not the model layer.

Usually, you add this to your AppServiceProvider:

Model::unguard();

Or using $guarded = [] on the model itself. Now every field is fillable, no maintenance of a list required.

This is a valid opinion. If you trust your FormRequests to filter input correctly, the model-level guard is indeed redundant. But it requires discipline: every endpoint must validate strictly, every new field must be accounted for in every request that touches the model. One missed $request->all() and anything goes through.

For me, unguarding is trading one problem (maintaining $fillable) for a bigger one (trusting every developer on the team to never pass unvalidated data to create()). In a large team, that’s a lot of trust.

The Point

Mass assignment isn’t broken. For simple CRUD, it’s totally reasonable. But when your model has 20+ fields, multiple creation contexts, and a team of developers – explicit is better than magic.

The question to ask: can you open a controller method and immediately know what fields get written to the database? If the answer is “no, I need to check $fillable and the FormRequest and hope nothing slips through” – that’s the problem.

DTOs and Actions add some boilerplate. But they make the code honest: each endpoint writes exactly what it says it writes, nothing more.

TL;DR

  • Mass assignment with $fillable is fine for small, simple apps.
  • When things grow and your models pile up the fields, you lose track of what’s really getting written.
  • The issue isn’t just field count; it’s that $fillable has zero context. Anyone can set anything on accident.
  • Use a DTO per context to map request fields explicitly, and an Action to write to the model with explicit field names.
  • The best test: when you look at a controller, do you know-without checking three files-exactly what’s hitting the database? With mass assignment, probably not. With explicit mapping, you always do.

Author’s Note

Thanks for sticking around!
Find me on dev.to, linkedin, or you can check out my work on github.

Notes from real-world Laravel.

Ultimate AI Agent for Laravel (2026)

Laravel 13

Hey there, Laravel fam! 😎

I know what you’re thinking? Ultimate AI Agent for Laravel? Is it real? How? What? Exactly how’s it working? Which tool? Please spill the beans. I’m a Laravel developer since years, running my own Laravel agency, Laravel freelancer with a strong team of Laravel wizards. We know everything about the latest Laravel 13 release, Taylor’s Laravel AI SDK drop, but still, an AI agent built exclusively for Laravel? What exactly is it? I wanna try it NOW.

Laravel 13

Heard of Laracopilot? Maybe somewhere, if you hit up Laracon India, Laracon US, or Laracon Europe. Yeah, they had their booth rocking those conferences. But even if not, you gotta know about the AI Agent for Laravel. Spoiler: Laracopilot is THE right tool, the ultimate AI agent for Laravel!

LaraCopilot UI

As a battle-tested Laravel pro who’s shipped dozens of projects for clients worldwide, I’ve seen every tool under the sun. From VS Code extensions to full-blown IDEs, nothing comes close to Laracopilot. Let me break it down for you, straight from the trenches. Because I’ve been using it hardcore, and guys, I LOVE this tool. Straight up, it’s a game-changer. I built noonlaunch.com with it. That’s my launchpad for founders, a full-stack beast helping indie hackers ship MVPs lightning-fast. Noonlaunch.com went from idea to live in days, not weeks, all powered by Laracopilot’s magic. If you’re a solo freelancer like me or running an agency with a team, this is your secret weapon. No cap, it’s the ultimate AI agent tailored for Laravel devs who wanna build faster, smarter, and scale like pros.

So, what’s Laracopilot exactly? Picture this: It’s not just some generic AI chatbot slapping code at you. Nah, it’s an AI-native builder EXCLUSIVELY for Laravel. You chat with it like you’re pair-programming with a senior dev who knows Laravel inside out. Routes, migrations, Eloquent models, Livewire components, you name it. Type in natural language: “Build me a user auth system with social logins and email verification,” and boom. It generates pristine, production-ready Laravel code. But it doesn’t stop there. It understands your entire project context, refs your codebase, and iterates based on your feedback. Undo a prompt? Revert changes in one click. It’s like having Taylor Otwell’s brain cloned into your IDE, but way more fun and hands-on.

Laracopilot has evolved into a much stronger Laravel AI Builder 🚀. Here’s what’s new and why it’s blowing my mind:

  • Private GitHub repo integration. Connect your private repos seamlessly. No more copy-paste hell. It pulls your code, understands your structure, and builds on top. I hooked up my noonlaunch.com repo, and it instantly grokked my custom middleware and queues.
  • Invite team members. Agency owners, rejoice! Bring your Laravel squad in. Collaborate in real-time, assign AI tasks, review changes together. My team loves it. No more “wait, what’s this endpoint do?” confusion.
  • One-click Laravel Cloud deployment. Built it? Deploy it. Straight to Laravel Cloud (or your Vapor setup) with zero config headaches. I pushed noonlaunch.com live in under 5 minutes. Game over for deployment drama.
  • Build & Design modes. Switch between raw code generation (Build) and UI/Blade wizardry (Design). Want Tailwind-styled dashboards? It spits out responsive components that just work.
  • Import any existing Laravel project. Got a legacy app? Upload it, and Laracopilot dives in instantly. Refactors, adds features, fixes bugs. Without breaking your vibe. I migrated an old client CRM this way; saved weeks of manual work.
  • New subscription plans. Flexible tiers for freelancers (me!), agencies, and enterprises. Starts affordable, scales with your usage. No vendor lock-in.
  • Revert changes (undo prompt). Made a whoopsie? Undo that AI suggestion in seconds. Safer than Git alone.

Now it’s more powerful, more reliable, and truly AI-native for Laravel. Built exclusively for Laravel, it leverages the latest from Laravel 13. Think better caching, improved queues, and that sweet AI SDK integration Taylor teased. But Laracopilot takes it further: It’s context-aware, so it remembers your app’s domain (e.g., “saas” vs “ecom”), suggests best practices like proper Sanctum auth or Horizon jobs, and even optimizes for performance out the gate.

My personal take? I LOVE this tool. Hands down, it’s the best thing since Artisan commands. Before Laracopilot, building noonlaunch.com. A launchpad for founders with user dashboards, Stripe integrations, waitlist management, and founder tools. Took my team forever. Routes alone? Nightmare. Models? Endless tweaks. With Laracopilot, I prompted: “Create a founder onboarding flow with Stripe checkout, email sequences via Mailcoach, and Reverb real-time updates.” It generated everything: migrations, controllers, jobs, views, tests. I tweaked twice, deployed once. Live. Founders are loving it. Noonlaunch.com is crushing it, all thanks to this ultimate AI agent.

Why does it work so damn well? Because it’s Laravel-first. Generic AIs like Claude or GPT? They hallucinate bad code or forget namespaces. Laracopilot? Trained on real Laravel repos, it knows php artisan make:whatever flows, follows PSR standards, and integrates with your stack (Pest tests, Filament panels, Inertia.js.pick your poison). Plus, it’s secure: Private repos stay private, no data leaks.

Heard it at Laracon? Yeah, their booths were buzzing. Demos had devs lining up. If you missed Laracon India or US, no sweat; jump in now. Pricing? Freemium to start, pro plans from $19/mo. ROI? Insane. I cut dev time by 70% on noonlaunch.com. Agency clients? They’re paying premium because I deliver faster.

Freelancers, agency leads, solo hustlers.don’t sleep on this. Laracopilot isn’t hype; it’s the future of Laravel dev. Install it, import your project, prompt away. Your next big app (like my noonlaunch.com) is waiting.

Try Laracopilot today at laracopilot.com. Link your GitHub, build something epic. Who’s with me? 🚀

I Built a Skill Reviewer. Then I Ran It on Itself.

I built a tool that reviews Claude Code skills for quality issues.

Then I pointed it at its own source files. It found real problems.

The irony wasn’t lost on me. But the more interesting question is: why did this happen, and what does it tell us about how LLM-based quality tools actually work?

The Setup

I maintain rashomon, a Claude Code plugin for prompt and skill optimization. It includes a skill reviewer agent that evaluates skill files against 8 research-backed patterns (BP-001 through BP-008) and 9 editing principles.

One of those patterns—BP-001—says: don’t write instructions in negative form. Research shows LLMs often fail to follow “don’t do X” instructions—negated prompts actually cause inverse scaling, where larger models perform worse. The fix is to rewrite them positively: instead of “don’t skip P1 issues,” write “evaluate all P1 issues in every review mode.”

Simple enough.

Except both my agent definition files had a section called ## Prohibited Actions full of “don’t” instructions.

The Discovery

I noticed this by reading my own code. But I wanted to see what happens when the tools catch it—or don’t.

First, I ran the prompt-analyzer agent against both files. It analyzed them, found some issues, but gave the Prohibited Actions sections a pass. Its reasoning: these qualify as “safety-critical” exceptions to BP-001, since they constrain “destructive” behaviors.

That felt off. “Don’t invent issues not supported by BP patterns” isn’t a safety-critical instruction. It’s a quality policy. The caller can override or discard the output.

So I ran the skill-reviewer agent against the same two files. The results were more interesting.

For skill-reviewer.md (reviewing itself), it flagged all four items in Prohibited Actions as BP-001 violations—P2 severity. Correct call.

For skill-creator.md (reviewing the other agent), it gave Prohibited Actions a pass. Same structure, same pattern, opposite judgment.

The same reviewer, applying the same criteria, reached opposite conclusions on the same construct.

Digging Into Logs

I could have speculated about why. Instead, I checked the subagent conversation logs.

The skill-creator review log showed this in the Step 1 pattern scan:

BP-001 (Negative Instructions): Lines 197-202 “Prohibited Actions” section uses negative form. However, per the BP-001 exception in skills.md, these are procedural/irreversible consequences (inventing knowledge, removing examples, overwriting files). The exception applies.

It did scan for BP-001. It found the section. But it classified the items as “irreversible consequences” and applied the exception.

The problem was clear: the exception rule said negative form is okay for “safety-critical operations, destructive actions, or order-dependent procedures.” That’s vague enough to stretch. “Inventing domain knowledge” sounds serious. “Removing user-provided examples” sounds destructive. If you squint, anything can be “destructive.”

Nothing was wrong with the reviewer. It was doing exactly what I told it to do. That was the problem.

Fixing the Criteria, Not the Reviewer

The instinct is to blame the LLM: “it self-justified,” “it was biased toward leniency.” But the actual cause was simpler: the exception rule was written in a way that allowed two reasonable readings.

The fix wasn’t to make the reviewer “smarter.” It was to make the criteria harder to misread.

I replaced the broad exception language:

Exception: safety-critical operations, exact command sequences,
destructive actions, or order-dependent procedures

With a 4-condition checklist:

Exception: Negative form is permitted only when ALL are true:
(1) Violation destroys state in a single step
(2) Caller or subsequent steps cannot normally recover
(3) The constraint is operational/procedural, not a quality policy
(4) Positive rewording would expand or blur the target scope

And added concrete boundary examples—what qualifies, what doesn’t:

Permitted (exception applies) Not permitted (rewrite positively)
“Do not modify the command” “Do not invent issues” -> “Base every issue on BP patterns”
“Do not add flags” “Do not skip P1 issues” -> “Evaluate all P1 in every mode”
“Do not execute destructive operations” “Do not create overlapping skills” -> “Verify no overlap before generating”

The key addition: “Outputs that the caller validates, overwrites, or discards are never irreversible.” This one sentence eliminates most of the ambiguity. A subagent’s output goes to a caller. The caller decides what to do with it. That’s not irreversible.

The Retest

After updating the criteria, I ran the skill-reviewer again on both files.

skill-reviewer.md: Prohibited Actions flagged as BP-001 P2. All four items caught.

skill-creator.md: Two items flagged as quality policies that should be positive form. The remaining items—which are genuinely about operational constraints—were accepted.

Consistent. Explainable. And the reviewer could now articulate why each item was or wasn’t an exception, because the criteria forced it to check specific conditions rather than make a gestalt judgment.

But I wasn’t fully satisfied. In a further round of testing, the reviewer still occasionally applied exceptions loosely—recording “irreversible” in the justification field without explaining how it’s irreversible.

So I added structured evidence to the output schema:

"patternExceptions": [{
  "pattern": "BP-001",
  "location": "section heading",
  "original": "quoted text",
  "conditions": {
    "singleStepDestruction": "true|false + evidence",
    "callerCannotRecover": "true|false + evidence",
    "operationalNotPolicy": "true|false + evidence",
    "positiveFormBlursScope": "true|false + evidence"
  }
}]

You can’t just write “irreversible” anymore. You have to answer four yes/no questions with evidence. If any answer is no, it’s not an exception.

What This Comes Down To

The criteria had a loophole wide enough to drive a truck through. Better criteria produced better reviews without changing the reviewer at all. The LLM wasn’t “inconsistent”—the instructions were ambiguous. Two reasonable people could have read the old exception rule and reached different conclusions too.

Structured output helped more than I expected. The 4-condition checklist wasn’t just about auditability—it changed how the reviewer thinks. When you have to fill in four fields with evidence, you can’t hand-wave. The output structure becomes a thinking scaffold.

And running the tool on its own source files was uncomfortable in a useful way. The temptation is to say “well, I know what I meant.” But the tool doesn’t know what I meant. It reads what I wrote.

The Broader Problem: Skill Quality Is Hard

If you’re building Claude Code skills, custom agents, or any kind of structured LLM instruction set—you’ve probably experienced this: the instructions work fine in your head, but the LLM does something unexpected. You add more instructions. It gets worse. You simplify. Something else breaks.

The issue is that you can’t see your own blind spots. You know what you meant. The LLM reads what you wrote. The gap between intent and text is where bugs live.

This is why I built rashomon. It includes:

  • Skill review: Evaluate skill files against BP-001~008 patterns and 9 editing principles, with structured quality grades
  • Golden scenario evaluation: Test whether a skill actually works by comparing execution results with and without the skill, or before and after changes—not just whether it was loaded, but whether it made a measurable difference

The golden scenario part matters. “The skill was loaded” doesn’t mean “the skill helped.” You need to see the actual output difference to know if your skill is doing anything useful.

Try It

Rashomon is a Claude Code plugin. Install it and point the skill reviewer at your own skills.

# In Claude Code
/plugin marketplace add shinpr/rashomon
/plugin install rashomon@rashomon
# Restart session to activate

It will find problems. I know because it found problems in itself—and it’s better for it now.

What’s your experience with skill quality? Have you found ways to validate that your instructions actually do what you think they do?

I was tired of breaking my Traefik mTLS config, so I built a GUI for it

I was tired of breaking my Traefik mTLS config, so I built a GUI for it

If you’ve ever tried to set up mutual TLS (mTLS) with Traefik, you know the feeling. You get it working. Something changes. You dig back into YAML, reference the docs, check the cert paths, wonder if it’s the entrypoint config or the dynamic config or the certificate store. An hour later you’re back where you started.

I run Traefik at home to expose a bunch of self-hosted services — some on my LAN only, some to the internet, some requiring client certificates for extra security. After one too many “why is mTLS broken again” sessions, I decided to build something that would make this manageable once and not have to think about it again.

The result is traefik-gui: a lightweight web GUI for managing Traefik, packaged as a single Docker container.

What it does

The core features:

mTLS certificate management is the reason this exists. You can create and manage client certificates from the UI. When you create a cert for a device or user, you download a zip containing all the formats you’d need (PEM, P12, etc.) plus a README explaining how to install it — on a phone, browser, or system. No more OpenSSL one-liners.
Access log viewer lets you tail and browse Traefik access logs without SSHing into your server. When something’s not routing right, this is usually the first place you look.
Static config editor means you can edit traefik.yml from the browser.
Dynamic file config gives you both simple forms for common use cases and raw YAML editing for everything else.

The opinionated part

This tool makes assumptions. It assumes you’re:

  • Using file-based dynamic configuration (not Kubernetes or Consul)
  • Wanting to expose services on both your LAN and the internet
  • Managing your own certificates (not relying solely on Let’s Encrypt ACME)

Those assumptions are baked into the UI — there are two entrypoints, mTLS is supported on one of them, and the forms are designed around that workflow. If your setup matches, it feels polished. If it doesn’t, there are more flexible tools out there (Mantrae, Traefikr — both worth looking at).

Getting started

Single container, no database, Go backend:

# docker-compose.yml
services:
  traefik-gui:
    image: ghcr.io/deputynl/traefik-gui:latest
    container_name: traefik-gui
    restart: unless-stopped
    environment:
      TRAEFIK_CONFIG_PATH: /etc/traefik/traefik.yml
      TRAEFIK_API_URL: http://traefik:8080
      TRAEFIK_GUI_USER: ${TRAEFIK_GUI_USER:-admin}
      TRAEFIK_GUI_PASSWORD: ${TRAEFIK_GUI_PASSWORD:-admin}
      TRAEFIK_ACME_PATH: /etc/traefik/acme.json
      TRAEFIK_CONTAINER_NAME: traefik
    volumes:
      - /etc/traefik:/etc/traefik          # read+write for config changes
      - traefik-mtls:/etc/traefik/mtls     # CA, CA key + client certs, persisted across restarts
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - traefik
    ports:
      - 8888:8888

networks:
  traefik:
    external: true

volumes:
  traefik-mtls:

Open http://localhost:8888 and you’re in.

Why Go, why single container

Go compiles to a single binary with no runtime dependencies, which made packaging as a minimal container straightforward. The whole thing is one docker run away from working. No Node, no Python, no database to manage.

What’s next

It’s a hobby project, but I use it daily and intend to keep maintaining it. If you run into issues or have ideas, issues and PRs are welcome on GitHub.

→ github.com/deputynl/traefik-gui (MIT)

If you’re running Traefik at home and have questions about mTLS setup, happy to help in the comments.

Building a Real-Time AI Dungeon Master with Claude API, Socket.io, and Next.js 16

The AI in gaming market sits at $4.54 billion in 2025 and is projected to hit $81.19 billion by 2035 (SNS Insider, 2025). That number isn’t surprising when you think about what generative AI actually unlocks for games infinite narrative branching, dynamic NPCs, and a Dungeon Master who never gets tired at midnight.

I built DnD AI, a multiplayer AI Dungeon Master running on Next.js 16, Claude API (claude-sonnet-4-6), Socket.io, and DALL-E 3. This post is a technical walkthrough of the six hardest problems I ran into, and how I solved them. No fluff just the architecture decisions that actually mattered.

TL;DR:

  • Next.js App Router can’t maintain persistent WebSockets, so a custom server.ts boots Socket.io and Next.js in one process
  • Claude streaming output pipes through Socket.io to all connected clients in real time, with chunk batching to avoid socket flooding
  • DALL-E 3 fires only on location changes and major story beats not every message keeping session cost under $0.25

Why a Custom Server Instead of Next.js API Routes?

Next.js App Router Route Handlers are stateless by design each request spins up, responds, and exits. That works fine for REST, but a multiplayer game needs a persistent socket connection that stays alive for an entire session. There’s no clean way to run Socket.io inside an App Router handler. The solution is a custom server.ts at the project root that boots both runtimes in a single Node process.

The key insight: Next.js exposes a createServer API that lets you hand off HTTP requests to the Next.js handler while Socket.io attaches to the same HTTP server instance. Both share one process, one port, and one set of environment variables.

// server.ts (root of project, not inside /app)
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server as SocketIOServer } from "socket.io";
import { registerGameHandlers } from "./src/lib/socket/gameHandlers";

const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);
    handle(req, res, parsedUrl);
  });

  // Socket.io attaches to the same HTTP server
  const io = new SocketIOServer(httpServer, {
    cors: { origin: process.env.NEXT_PUBLIC_APP_URL },
  });

  // Register all game-related socket handlers
  registerGameHandlers(io);

  httpServer.listen(3000, () => {
    console.log("> Ready on http://localhost:3000");
  });
});

One gotcha: your package.json build script needs to compile server.ts separately with tsc, then run the compiled output not next start. Keep that in mind before you hit deploy.

The Game Loop Architecture

Every player action travels through a consistent five-step loop: client input, socket event, Claude API call, streamed narrative back to all clients, then stat resolution. Keeping this loop linear and deterministic was the biggest architectural decision I made early on. It made debugging a lot easier.

Player types/speaks action
        │
        ▼
[Socket.io] "player:action" event → server
        │
        ▼
[gameHandlers.ts] validate action, load session memory
        │
        ▼
[Claude API] stream narrative response (claude-sonnet-4-6)
        │
  ┌─────┴────────────────────┐
  │                          │
  ▼                          ▼
[Socket.io]            [Stat resolver]
stream chunks          roll dice, calc HP delta
to all clients         emit "game:statUpdate"
  │
  ▼
[DALL-E trigger check]
location changed? major beat?
→ fire async image gen

The stat resolver runs in parallel with the stream, not after it. Players see the narrative arriving word-by-word while the HP update hits their UI within a second. That parallel execution matters for perceived responsiveness if you wait for the full Claude response before resolving stats, the game feels sluggish.

The Hardest Part: Syncing Streaming LLM Output Across Multiple Clients

84% of developers now use AI tools daily (Stack Overflow Developer Survey, 2025), but most of those integrations are single-user. Streaming Claude’s output to four simultaneous Socket.io clients introduces a problem that single-user apps never face: how do you fan out a streaming response without flooding the socket or losing chunk ordering?

The naive approach emit a socket event for every token causes event queue saturation at ~4 clients with a fast model. I batched chunks into 50ms windows instead. Each batch emits one socket event with concatenated text. Clients append to their local buffer and re-render.

// src/lib/socket/streamHandler.ts
import Anthropic from "@anthropic-ai/sdk";

export async function streamNarrativeToRoom(
  io: SocketIOServer,
  roomId: string,
  messages: Anthropic.MessageParam[],
  systemPrompt: string
) {
  const client = new Anthropic();
  let chunkBuffer = "";
  let flushTimer: NodeJS.Timeout | null = null;

  const flush = () => {
    if (chunkBuffer.length > 0) {
      // Single emit per batch window all clients in room receive it
      io.to(roomId).emit("dm:narrative_chunk", { text: chunkBuffer });
      chunkBuffer = "";
    }
    flushTimer = null;
  };

  const stream = await client.messages.stream({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system: systemPrompt,
    messages,
  });

  for await (const chunk of stream) {
    if (
      chunk.type === "content_block_delta" &&
      chunk.delta.type === "text_delta"
    ) {
      chunkBuffer += chunk.delta.text;

      // Batch: flush every 50ms, not every token
      if (!flushTimer) {
        flushTimer = setTimeout(flush, 50);
      }
    }
  }

  // Flush any remaining buffer after stream ends
  if (flushTimer) clearTimeout(flushTimer);
  flush();

  io.to(roomId).emit("dm:narrative_end", { roomId });
}

The 50ms batch window is the sweet spot I landed on after testing. At 20ms the socket still floods at high token velocity. At 100ms the streaming effect feels choppy to users. Your mileage will vary depending on average token rate.

Dice Determinism in a Distributed Game

In a multiplayer game, dice rolls can’t be client-side. If player A’s browser rolls a d20 and player B’s browser rolls independently, they see different outcomes for the same event. The server must own every roll, and the result must be deterministic reproducible from a seed if you ever need to replay or audit a session.

I use a seeded pseudo-random number generator (PRNG) on the server, seeded per session. The seed gets stored in SQLite alongside the session record. When a roll happens, the server increments a counter, derives the roll from prng(sessionSeed + rollCount), emits the result to all clients simultaneously, and stores it.

// src/lib/dice/seededRng.ts
// Mulberry32 fast, seedable, good distribution for game use
function mulberry32(seed: number) {
  return function () {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function createSessionDice(sessionSeed: number) {
  const rng = mulberry32(sessionSeed);
  return {
    roll: (sides: number) => Math.floor(rng() * sides) + 1,
  };
}

The first version used Math.random() server-side and it worked fine until I added session replay for debugging. Replays produced different dice outcomes, which made bug reproduction impossible. Seeding costs nothing and saves you later.

Persistent Memory Without Blowing the Context Window

Claude’s context window is large, but sending full game history on every turn is expensive and eventually hits limits. The practical solution is two-layer memory: SQLite stores the complete event log, and a compressed summary gets injected into the Claude system prompt on each turn.

Prisma manages the schema. Each session has a GameSession record, a log of GameEvent rows (player actions, DM responses, stat changes), and a MemorySummary that gets regenerated every 10 turns using a separate, cheaper Claude call.

Most tutorials suggest simply truncating history. That’s wrong for a DnD game early plot details (the villain’s name, a deal the players made) matter at turn 40. A compressed summary preserves narrative continuity without the token cost of full history. The summarization prompt is as important as the main DM prompt.

The system prompt structure:

// src/lib/ai/buildSystemPrompt.ts
export function buildDMSystemPrompt(
  campaignConfig: CampaignConfig,
  memorySummary: string,
  recentEvents: GameEvent[]  // last 5 events in full
): string {
  return `
You are the Dungeon Master for a ${campaignConfig.theme} campaign.
Setting: ${campaignConfig.worldDescription}
Players: ${campaignConfig.players.map(p => `${p.name} (${p.class}, HP: ${p.currentHp}/${p.maxHp})`).join(", ")}

## Story So Far (compressed)
${memorySummary}

## Recent Events (verbatim)
${recentEvents.map(e => `[${e.type}] ${e.content}`).join("n")}

Rules: Stay in character. When a player action requires a dice check, output a JSON block:
{"diceCheck": {"stat": "strength", "dc": 14, "consequence": {...}}}
  `.trim();
}

DALL-E 3 When to Trigger and How to Manage Cost

DALL-E 3 at $0.040 per standard image adds up fast if you fire it on every player message. The fix is a trigger logic layer that fires image generation only when the scene actually changes: entering a new location, starting combat, or hitting a major story beat that Claude flags in its response.

In testing across 12 playthroughs averaging 45 turns each, trigger-gated generation fired DALL-E 3 an average of 6 times per session keeping image cost under $0.25 per session. Ungated, the same sessions would have triggered 40+ image calls.

Image generation runs async the game doesn’t wait for it.

// src/lib/ai/imageOrchestrator.ts
type SceneSignal = { sceneChange: true; description: string } | null;

export async function maybeGenerateSceneImage(
  roomId: string,
  dmResponse: string,
  io: SocketIOServer
): Promise<void> {
  const signal = extractSceneSignal(dmResponse); // parses JSON block in response
  if (!signal) return;

  // Fire and forget don't await in the main game loop
  generateAndEmit(roomId, signal.description, io).catch((err) =>
    console.error("Image gen failed silently:", err)
  );
}

async function generateAndEmit(
  roomId: string,
  description: string,
  io: SocketIOServer
) {
  const imageUrl = await generateImage(description);
  io.to(roomId).emit("scene:image_ready", { imageUrl });
}

The async fire pattern means players get a scene image 5–8 seconds after a location change, not blocking any game action. The UI shows a loading shimmer until scene:image_ready arrives.

Web Speech API in Production

Web Speech API works well for the happy path clear speech, modern Chrome, quiet environment. It breaks in ways that are hard to predict. Browser support is inconsistent outside Chrome and Edge. Background noise triggers false positives. Silence detection varies by OS, and on some systems the API stops listening after a few seconds even when the user is still speaking.

The two real-world failures I hit most: (1) mobile Safari doesn’t support the API at all in some iOS versions, and (2) the onend event fires too aggressively in noisy environments, cutting off player actions mid-sentence. The fix for the second issue was a 1.5-second debounce on the onend event before submitting the transcript.

The connection to the action pipeline is simple: the browser captures speech, converts to text, and fires the same player:action socket event that a typed message would send. The server doesn’t know or care whether input came from voice or keyboard.

Always provide a text input fallback. Don’t ship a voice-only interface.

What I’d Do Differently

Over-engineered: the Prisma schema. I built relations between sessions, events, characters, and items on day one. For a prototype, a flat JSON blob in SQLite would have been faster to iterate on and just as queryable for my needs.

Under-engineered: the Claude system prompt. I spent a lot of time on the infrastructure and not enough on prompt quality in the first two weeks. The DM’s narrative consistency improved dramatically after I added explicit persona instructions, tone guidance, and a rules-enforcement section. Infrastructure is the easy part.

The biggest surprise: streaming to multiple clients is genuinely tricky. I expected it to be a minor detail. The 50ms batching window took three days of testing to land on. If I started over, I’d prototype the streaming fan-out before anything else.

90% of game developers already use AI in their workflows, and 97% say generative AI is reshaping the industry (Google Cloud / Harris Poll, Aug 2025). The tooling is mature. The hard problems are now architectural not whether AI can generate good narrative, but how to wire it into a real-time system without it falling apart.

FAQ

Can this architecture scale beyond 4 players?

The Socket.io room model scales to more players without code changes. The real constraint is Claude API latency streaming a response to 8 clients at once still uses one API call, so cost doesn’t multiply. In testing, the 50ms batch window held stable at 6 clients. Beyond that, you’d want to profile socket event queue depth under load.

Why Claude over GPT-4 for the DM role?

Claude’s longer context window and instruction-following consistency made it the better fit for maintaining campaign continuity across a long session. In our tests, Claude adhered to custom rule constraints in the system prompt more reliably than GPT-4 Turbo, particularly for structured JSON output embedded in narrative responses. That JSON output drives the dice check and stat resolution pipeline.

How do you handle Claude going off-script or breaking game rules?

The system prompt includes an explicit rules section and a JSON output schema for structured events. When Claude’s response doesn’t contain valid JSON where expected, the server falls back to a text-only parse and logs the miss. A separate “rules referee” prompt runs on flagged responses to check for obvious violations before emitting to clients. It catches about 80% of off-script outputs without blocking the stream.

AS Notes – VS Code Notes, Docs and Wikilink Markdown Editing

Wikilinks

What is AS Notes

AS Notes brings markdown editing with [[wikilink]] for notes, documentation, blogs and wikis directly into VS Code and compatible editors (e.g. Antigravity, Cursor, Windsurf).

With AS Notes, you can capture ideas, link concepts, write, and stay focused – without ever leaving your editor.

AS Notes provides productivity tooling that turns your favourite IDE into a personal knowledge management system (PKMS), including a backlinks view, task management, journals, a kanban board, markdown editing tools, mermaid, LaTeX math support and Jekyll / Hugo like publishing.

Install: Visual Studio Marketplace / Open VSX
GitHub: github.com/appsoftwareltd/as-notes

Resource Url
Install Visual Studio Marketplace / Open VSX
Web asnotes.io

Why Did I Build AS Notes?

There are many note taking apps in various forms, for various devices supporting various formats. Why do we need another one?

Firstly, I’m a big fan of note taking / second brain / zettelkasten apps like Logseq and Obsidian. The main draw of these apps for many is that they offer bi-directional linking tools and importantly, are based on markdown – an open text based format that comes with inherent portability and longevity that doesn’t come with closed proprietary formats that you might see in apps like OneNote or those that store your information in remote databases like Notion. I want to know that my thoughts and writing will be available to me in 30 years, without me having to continuously pay monthly subscriptions. Markdown provides that. There will always be text files and markdown will always be simple to parse, meaning that notes in markdown can survive the rise and fall of any editing tool. Secondly, markdown based tools facilitate local editing, which offers options for the privacy conscious.

As a software developer however, the existing tools didn’t cover everything I needed them to. Not all work environments will allow personal knowledge management tools with the potential to sync data to private clouds (for obvious reasons). And while they may support publishing in various forms, they are not designed for building and maintaining documentation for software projects.

I wanted fast, simple wikilinks, bi-directional linking and backlinking in my documents. This feature allows both the author and the user to infer relationships between key concepts in your applications, and we’re more likely to write good notes and documentation when our tooling make it easy.

AS Notes goes further by also providing markdown editing tooling along with task and productivity management tools.

Full documentation and features are available at docs.asnotes.io

AS Notes Works Where You Work, and With Your Existing Tools

Many of us spend a lot of our day in VS Code and VS Code derived editors like Cursor, Antigravity or Windsurf. What I really wanted was the power of a PKMS (personal knowledge management system), with wikilinking and fast information capture, but with the flexibility to also write and maintain comprehensive, well formatted technical documentation, along with options for publishing, and I needed it to work where I spend most of my day – in the IDE.

Further, I believe in version controlling everything – code, notes and documentation. I need to work with Git friendly tools and formats. VS Code is a text editor. Text is Git friendly, and VS Code has version control tooling built in.

The Best Place to Maintain Documentation (and Notes) Is With Your Code

I firmly believe that documentation is best maintained right next to a code base. This can also be extended to general project and planning notes too, depending on how far you want to take the idea. The reason for this is that you are far more likely to keep your documentation up to date when you are able to edit and version control docs alongside code, due to proximity and easy switching between the files.

Docs next to code also lends itself to publishing documentation to static websites, providing the option of making your documentation available via GitHub / Cloudflare Pages or other providers, and having those documents published as part of your build pipeline (CI/CD).

AS Notes can be initialised at the top level working directory, or a single subfolder (For example you may not want AS Notes active in basic README.md or LICENCE.md files). AS Notes also supports Jekyll / Hugo like publishing of selected documents or entire directories, with layout and theme support.

Features At A Glance

Wikilinks and Nested Wikilinks

Link notes together with [[wikilinks]], including support for nested links like [[[[AS Notes]] Page]] that resolve multiple targets. Renaming a link updates the target file and all matching references across your workspace.

Wikilinks

Inline Editor Styling

Optional Typora-like inline rendering that replaces markdown syntax with visual formatting as you type. Syntax characters fade to ghost opacity when the cursor is on the line, and appear in full when editing inside a construct.

Inline Editor Styling

Mermaid and LaTeX Rendering

Mermaid diagrams and LaTeX math expressions render inline in the editor, with live updates as you edit.

Mermaid and LaTeX rendering

Backlinks View

See every reference to the current page in a dedicated panel. Backlinks capture surrounding context, support forward references and update live as you edit. Group by page or by chain pattern for concept-based exploration.

Baclkinks View

Task Management

Toggle markdown TODOs with a keyboard shortcut and view all tasks across your workspace in a dedicated sidebar. Group by page, priority, due date or completion date, and filter by status or page name. Add structured metadata tags (#P1, #W, #D-YYYY-MM-DD) to categorise and organise tasks.

Task management

Kanban Board

A built-in kanban board backed by markdown files for tracking long-running projects. Cards are regular notes so you can use wikilinks, tasks and all other AS Notes features inside them.

Kanban Board

Daily Journal

Press a keyboard shortcut to create or open today’s journal entry. New entries are generated from a customisable template with placeholder support. A calendar panel in the sidebar shows the current month with journal indicators.

Daily Journal

Outliner Mode

Turn the editor into a bullet-first outliner where every line begins with -. Custom keybindings for Enter, Tab, Shift+Tab and Ctrl+Shift+Enter keep you in flow with automatic bullet continuation, indentation control and todo cycling.

Outliner mode

Templates

Example Demo Template.md – when used via the /template slash command , the template below would result in the following markdown and cursor placement:

# {{title}} - Template Placeholder Demo

Created: {{date}}
Time: {{time}}
Full timestamp: {{datetime}}

## File Info

- **Filename:** {{filename}}
- **Title:** {{title}}

## Custom Date Formats

- UK format: {{DD/MM/YYYY}}
- US format: {{MM/DD/YYYY}}
- Short year: {{DD/MM/YY}}
- Time only: {{HH:mm}}
- Full custom: {{DD/MM/YYYY HH:mm:ss}}
- Year-month: {{YYYY-MM}}
- Day-month: {{DD-MM}}

## Notes

{{cursor}}

Result:

# 2026-04-02 - Template Placeholder Demo

Created: 2026-04-02
Time: 12:15:20
Full timestamp: 2026-04-02 12:15:20

## File Info

- **Filename:** 2026-04-02
- **Title:** 2026-04-02

## Custom Date Formats

- UK format: 02/04/2026
- US format: 04/02/2026
- Short year: {{DD/MM/YY}}
- Time only: 12:15
- Full custom: 02/04/2026 12:15:20
- Year-month: 2026-04
- Day-month: 02-04

## Notes

<- Cursor here

Encrypted Notes

Store sensitive notes in encrypted .enc.md files using AES-256-GCM encryption. Your passphrase is stored securely in the OS keychain and never written to disk. Encrypt and decrypt notes with a single command.

Static Site Publishing

Publish selected notes or entire directories as a static HTML site with layout and theme support, similar to Jekyll or Hugo. Ideal for documentation sites, blogs, and project wikis deployed via GitHub Pages, Cloudflare Pages or other providers.

Static Site Publishing

Installing AS Notes

If you’d like to use AS Notes to manage your notes or documentation, you can install via:

Visual Studio Marketplace / Open VSX

Conclusion / Feedback / Questions

If you have any questions or feedback regarding AS Notes, contact us via asnotes.io or email [email protected]

BrewOps: A Production-Grade HTCPCP Dashboard

This is a submission for the DEV April Fools Challenge

What I Built

I built BrewOps, a highly serious, production-grade DevOps dashboard for a delightfully useless protocol: the Hyper Text Coffee Pot Control Protocol (HTCPCP, RFC 2324).

Tired of walking to the breakroom only to find the coffee pot empty? BrewOps brings 1998’s best internet joke into the modern era. It’s a sleek control center that lets you monitor your network of coffee pots and teapots. You can issue BREW and PROPFIND requests, select your Accept-Additions (like Milk, Syrup, or Alcohol), and watch the live terminal logs.

And yes, if you try to brew coffee using the “Earl Grey Teapot” appliance, the server will correctly reject your request with a 418 I'm a teapot status code, complete with a panic animation.

Demo

Live App: BrewOps HTCPCP Dashboard

Code

Source Code: Google AI Studio Project

How I Built It

I built this using Next.js and Tailwind CSS to give it that authentic, dark-mode “serious developer tool” aesthetic. The icons are from lucide-react, and I used motion/react (Framer Motion) to create the smooth terminal log entries and the bouncing teapot animation when a 418 error is triggered.

The entire project was generated, iterated on, and deployed using Google AI Studio.

Prize Category

I am submitting this for two categories:

1. Best Ode to Larry Masinter:
This project is a literal, playable implementation of Larry Masinter’s legendary RFC 2324. It faithfully recreates the HTCPCP headers (Accept-Additions, message/coffeepot content types) and intentionally triggers the famous 418 I'm a teapot error when you target the wrong appliance.

2. Best Google AI Usage:
I built this entire application from scratch using Google AI Studio (powered by Gemini 3.1 Pro). The AI agent helped me scaffold the Next.js app, design the Tailwind UI, write the simulated terminal logic, and instantly deploy the final build to Google Cloud Run (which is where it is currently hosted!).

How I Built a Free AI-Powered Article Analyzer That Scores Content for AI Search Readiness

Most SEO tools tell you if your content ranks on Google. None of them tell you if ChatGPT can cite it.

I built the AEO Article Analyzer to solve that problem. It scores any article against 10 criteria that determine whether AI search engines — ChatGPT, Perplexity, Gemini, Google AI Overviews — can extract, parse, and cite your content.

This is the technical story of how I built it, what I learned, and why the 10 scoring criteria matter.

The problem

I work as an SEO engineer for SaaS companies. Part of my job involves auditing content for AI search readiness — what the industry calls AEO (Answer Engine Optimisation) or GEO (Generative Engine Optimisation).

The manual process was slow. I’d open an article, check for a definition block in the first paragraph, look at the heading hierarchy, check for FAQ sections, look for author credibility signals, and mentally score it. Then do it again for the next article. And the next.

I wanted a tool that could do this in seconds, consistently, across hundreds of articles. Nothing on the market did this — existing tools focus on traditional SEO signals (keyword density, backlinks, meta tags), not on what makes content citable by AI.

So I built it.

The tech stack

Layer Technology
Frontend React 18 + TypeScript
Build Vite 5
Styling Tailwind CSS + shadcn/ui
Backend Supabase (auth, database, edge functions)
AI Claude Sonnet via OpenRouter API
Icons lucide-react
Fonts DM Sans + Space Grotesk

I chose this stack because I wanted something I could ship fast and iterate on. Supabase handles auth, database, and serverless functions in one platform. React + Tailwind is what I use for everything. The AI layer runs through OpenRouter so I can switch models without changing code.

How it works

The flow is straightforward:

  1. User pastes article text or enters a URL
  2. If URL: an edge function fetches the HTML and extracts the text content, preserving heading hierarchy as markdown. It also extracts any JSON-LD structured data (author, article schema, FAQ schema)
  3. The extracted text is sent to a Claude Sonnet edge function with a system prompt that evaluates 10 specific criteria
  4. Claude returns a structured JSON response with scores, pass/fail ratings, and specific suggestions per criterion
  5. The frontend renders a scorecard with an overall score (0-100), colour-coded criteria cards, and a prioritised action list

For long articles (over 15,000 characters), I use smart truncation: the first 12,000 characters plus the last 5,000, with a separator marker. This preserves both the opening (where definition blocks and H1s live) and the closing (where FAQs and author bios typically appear) while staying within token limits.

The 10 scoring criteria

This is the core of the tool. Each criterion scores 0-10, and the overall score is a weighted aggregate out of 100. Here’s what each one checks and why it matters for AI citation:

1. Question-based H1

Does the headline match the format of a prompt someone would type into ChatGPT? AI systems are trained on question-answer patterns. An H1 that reads like a question (“How does hreflang work for multilingual SaaS?”) is more likely to be matched to an AI prompt than one that reads like a feature list (“Hreflang Implementation Guide 2026”).

2. Direct answer in the first 200 words

AI systems extract answers from the opening of an article. If your first paragraph is a vague introduction, AI will skip to a competitor who gets to the point. The tool looks for a TL;DR block, a blockquote definition, a bold summary, or a callout — 40-80 words that directly answer the question in the H1.

3. Clear heading hierarchy

H1 → H2 → H3 in logical order. AI parses heading structure to understand content organisation. Broken hierarchies (H1 → H3, or multiple H1s) confuse the extraction process.

4. Modular sections (75-300 words each)

AI systems work best with self-contained sections that each make one point. Sections that are too long get truncated. Sections that are too short don’t provide enough context. The sweet spot is 75-300 words per section.

5. FAQ section with 5+ questions

FAQ sections are gold for AI citation. They’re structured as question-answer pairs — exactly the format AI is designed to surface. The tool checks for at least 5 questions with 40-80 word answers. It looks for bold text formatting (not just headings) since many FAQ implementations use bold questions.

6. Named sources and expert quotes

AI systems assess credibility through citations. Content that says “experts say” scores lower than content that says “Dr. Jane Smith, Head of Research at MIT, says…” The tool checks for real names with credentials.

7. Specific data and numbers

“Revenue increased significantly” vs “Revenue increased 340% from 1.56M to 6.89M.” AI systems prefer specific, verifiable claims. The tool checks for concrete figures backing claims.

8. Original insight

Can AI generate this content itself? If yes, there’s no reason to cite you. The tool checks for unique perspectives, first-hand experience, proprietary data, or contrarian viewpoints that couldn’t come from a language model.

9. No AI slop

Filler content, vague generalisations, and corporate jargon. Content that reads like it was generated by AI without human editing scores poorly because AI systems have no reason to cite more of what they can already produce.

10. Author credibility signals

Bio boxes, bylines, credentials, linked profiles. AI systems use author signals as a trust indicator — this maps directly to Google’s E-E-A-T (Experience, Expertise, Authoritativeness, Trust) framework.

Usage limiting without a paywall

I wanted the tool to be genuinely free — no credit card, no trial period. But I also needed to prevent abuse and manage API costs.

The solution: IP-based rate limiting with 3 analyses per month. Server-side tracking using Supabase, keyed on IP address + month string (e.g., “2026-04”). No cookies, no client-side workarounds.

// usage_tracking table
{
  ip_address: string,
  month_key: string,  // "2026-04"
  count: number,
  last_used: timestamp
}
// Unique constraint on (ip_address, month_key)

Admins bypass the limit for testing. The counter resets automatically each month based on the month_key format.

This approach means: no signup required to use the tool (lowering friction), no payment gate (it’s a lead magnet, not a product), and costs stay predictable.

The URL fetch challenge

The hardest part wasn’t the AI scoring — it was extracting clean text from arbitrary URLs.

The edge function fetches HTML with a standard User-Agent, then extracts text while preserving heading hierarchy as markdown. It also pulls JSON-LD structured data (author schema, article schema, FAQ schema) which feeds into the credibility scoring.

The limitation: this only works for server-rendered HTML. JavaScript-rendered SPAs (React, Next.js with client-side rendering, Angular) return minimal content because the edge function doesn’t execute JavaScript. For these sites, users paste the text directly.

This is actually a useful signal in itself — if your content requires JavaScript to render, AI crawlers might have the same problem extracting it.

What I learned building this

AI scoring needs guardrails. Early versions of the system prompt returned inconsistent scores. A “direct answer” score of 7 from one analysis might be a 4 on the same article the next time. I fixed this by making the scoring criteria extremely specific in the prompt: word count ranges, structural patterns to look for, and explicit examples of pass vs fail.

Users care about actions, not scores. The first version showed scores only. Users would see “6/10 on FAQ section” and not know what to do. Adding specific, prioritised action items (“Add a FAQ section with at least 5 questions. Each answer should be 40-80 words and start with a direct statement.”) made the tool dramatically more useful.

3 free uses is the right number. Enough to analyse your homepage, your best blog post, and a competitor’s page. That’s enough to see the pattern and understand the value. Most users who hit the limit either share the tool (free distribution) or reach out about a full audit (pipeline).

What’s next

I’m working on two additions:

  1. Batch analysis — upload a CSV of URLs and get a scored report for every page. This is the basis for the content inventory phase (Phase 5) of my 12-phase SEO & GEO audit.

  2. Competitor comparison — analyse your article alongside 2-3 competitor articles for the same query. Show exactly where you’re stronger and weaker on each criterion.

Try it

The AEO Article Analyzer is free at nadiamohamed.me/ai-tools/aeo-article-analyzer/. No signup required for the first 3 analyses.

I also built two other free tools:

  • Keyword Clustering — group up to 1,000 keywords into semantic clusters
  • AI Tool Ideas Generator — enter your niche, get tool ideas with demand estimates

All three are at nadiamohamed.me/ai-tools/.

If you’re building AI-powered SEO tools or working on GEO, I’d love to hear what you’re working on. Find me on LinkedIn or at nadiamohamed.me.

I’m Nadia — an SEO engineer specialising in GEO and technical SEO for SaaS companies. I build the organic growth infrastructure myself — structured data, tracking dashboards, automation workflows — instead of handing clients a PDF of recommendations.

A Serverless URL Shortener in a Single CloudFormation Script

Most URL shorteners are managed SaaS products that require you to send your data to third-party servers. If you want to maintain data sovereignty without the overhead of managing a complex application, we’ve condensed a full serverless stack into a single template.yaml script.

The “One Script” USP

There are no external dependencies, no complex CI/CD pipelines, and no multi-file microservices to coordinate. You upload one file to AWS CloudFormation, and the stack handles the rest—from the API ingress to the frontend UI.

The Architecture: S3 Metadata Redirection

The core “hack” of this stack is that it doesn’t use a database (no RDS, no DynamoDB) for redirections. Instead, it leverages S3 Object Metadata.
When a URL is shortened, the Lambda function creates an empty object in S3 with the shortened “slug” as the key and attaches the x-amz-website-redirect-location metadata. S3 static website hosting then handles the redirect natively. This results in:

  • 0ms logic latency on the redirect.
  • Near-zero cost (you only pay for S3 storage and CloudFront data transfer).
  • Infinite scaling without provisioning database throughput.

Feature Comparison

Feature Community (GPL-3.0) Paid IaaS Managed SaaS
Deployment us-east-1 only Single Script No Setup (API)
Deployment Single Script Single Script No Setup (API)
Region Single Script Global Global
URL Customization Random Slugs Custom Slugs Custom Slugs
Expiration None Time & Click limits Time & Click limits
Analytics None Click Tracking Click Tracking
License Non-Commercial Commercial/Enterprise Commercial/Enterprise
Support Community Live Chat / Email Live Chat / Email

How to Deploy (Community Edition)

The Community Edition is open-sourced on GitHub. It is a functional baseline for non-commercial projects in us-east-1.

  1. Download/Clone the Repo: silverlining-cloud/aws-self-hosted-url-shortener
  2. Upload the template:
aws s3 cp template.yaml s3://<YOUR_BUCKET_NAME>/
  1. Execute the Stack:
aws cloudformation create-stack --stack-name url-shortener 
--template-url https://<YOUR_BUCKET_NAME>.s3.amazonaws.com/template.yaml 
--capabilities CAPABILITY_NAMED_IAM 
--region us-east-1

Commercial & Enterprise Tiers

If you are deploying this for a client, a company, or need to run it outside of us-east-1, we offer two professional paths:

  1. Paid IaaS (AWS Marketplace): Purchase a commercial license and unlock multi-region support, custom slugs, and link expiration. Everything still runs in your own VPC.
  2. Managed SaaS (Marketplace API): Skip the infrastructure entirely. Use our API and pay $0.004 per link directly through your existing AWS/Azure bill.

Links
GitHub: Community Edition
Marketplace: Commercial Version
Documentation: API Playground

Quick Note: If you’re a Dev Lead looking to “burn down” remaining quarterly cloud credits, the Marketplace version is a pre-approved way to add this utility to your stack without a new procurement cycle.