How to Install Skills in Claude Code: 3 Methods

Claude Code becomes a lot more useful once you start adding skills to it. Skills are packaged instructions that teach Claude how to handle specific tasks, from writing commit messages to reviewing code to generating documentation. The good news is that installing them takes less than a minute.

There are three ways to add skills to Claude Code. This guide walks through each method with step-by-step instructions so you can pick whichever fits your workflow.

Before you start

Make sure Claude Code is installed and working. Open your terminal and run:

claude --version

If you get a version number back, you’re good. If not, install Claude Code first by following the instructions at claude.ai/install.

Skills are stored in two places:

  • Personal skills go in ~/.claude/skills/ and are available across all your projects
  • Project skills go in .claude/skills/ inside a specific repo and are shared with anyone who clones it

Most of the time, you’ll install to the personal directory unless you’re setting up skills for a team.

Method 1: The plugin marketplace

Claude Code has a built-in plugin system that lets you browse, install, and manage skills without leaving your terminal. This is the fastest method if the skill you want is available as a plugin.

Step 1. Start a Claude Code session in any project:

claude

Step 2. Type /plugin and press Enter. This opens the plugin browser.

Step 3. Navigate to the Discover tab. You’ll see a list of available plugins from the default Anthropic marketplace.

Step 4. Find the plugin you want and press Enter to install it. Claude will ask you to choose a scope:

  • User installs it for all your projects (~/.claude/skills/)
  • Project installs it for the current repo only (.claude/skills/)

Step 5. That’s it. The skill is now active. You can invoke it with /skill-name or let Claude load it automatically when it’s relevant.

Adding community marketplaces

The default marketplace only has Anthropic’s official plugins. To access community skills, add a third-party marketplace:

/plugin marketplace add author/repository

After adding a marketplace, its plugins show up in the Discover tab alongside the official ones.

Method 2: Manual file install

If a skill isn’t available as a plugin, or if you prefer to manage files yourself, you can install skills by copying them into the right directory. This works for skills downloaded as zip files, cloned from GitHub, or created by hand.

Installing from a zip file

Step 1. Download the skill zip file. On Agensi, click the download button on any skill page.

Step 2. Unzip it into your skills directory:

# Personal install (all projects)
unzip code-reviewer.zip -d ~/.claude/skills/

# Project install (current repo only)
unzip code-reviewer.zip -d .claude/skills/

Step 3. Verify the folder structure looks right:

ls ~/.claude/skills/code-reviewer/

You should see at least a SKILL.md file. Some skills also include scripts/, references/, or assets/ folders.

Step 4. Start a new Claude Code session. Claude will automatically detect the new skill.

Installing from GitHub

Step 1. Clone the repository:

git clone https://github.com/author/skill-name.git

Step 2. Copy the skill folder (not the whole repo) to your skills directory:

cp -r skill-name ~/.claude/skills/skill-name

If the repo contains multiple skills in subdirectories, copy just the one you want:

cp -r repo-name/skills/code-reviewer ~/.claude/skills/code-reviewer

Step 3. Start a new session and the skill will be available.

Verifying an install

After installing any skill, you can check that Claude recognizes it. Start a session and type:

/skill-name

If the skill loads, you’ll see Claude acknowledge it and follow the skill’s instructions. If nothing happens, double check that the SKILL.md file is in the right location and that the folder is directly inside ~/.claude/skills/ (not nested an extra level deep).

Method 3: Download from Agensi

Agensi is a marketplace for curated, security-reviewed SKILL.md skills. Every skill goes through an automated 8-point security scan before it goes live, so you know what you’re installing has been inspected.

Step 1. Browse skills at agensi.io/skills. Filter by category, price, or popularity.

Step 2. Click on a skill to see its detail page. Check the description, tags, and security scan results.

Step 3. Click Download (for free skills) or Buy (for paid skills). You’ll get a zip file.

Step 4. Unzip into your Claude Code skills directory:

unzip skill-name.zip -d ~/.claude/skills/

Step 5. Start a new Claude Code session. Done.

Agensi skills work with Claude Code out of the box, but most are also compatible with OpenClaw, Codex CLI, Cursor, Gemini CLI, and other agents that support the SKILL.md format.

Managing your installed skills

Over time you’ll accumulate skills. Here are a few useful things to know:

List all installed skills:

ls ~/.claude/skills/

Remove a skill: Just delete its folder.

rm -rf ~/.claude/skills/skill-name

Update a skill: Replace the folder with the new version. For GitHub skills, pull the latest changes.

Disable a skill temporarily: Rename the folder to something Claude won’t recognize:

mv ~/.claude/skills/code-reviewer ~/.claude/skills/_code-reviewer

Rename it back to re-enable.

Which skills to install first

If you’re just getting started, here are a few skills that most developers find useful right away:

  • git-commit-writer — writes conventional commit messages by analyzing your staged changes
  • code-reviewer — reviews your code for bugs, security issues, and best practices
  • pr-description-writer — generates clear pull request descriptions from your branch diff
  • env-doctor — diagnoses why your project won’t start

Browse more on the Agensi marketplace or check out the complete guide to SKILL.md if you want to understand how skills work under the hood.

The Wild West of ASP: A Love Letter to the Early Web

The Wild West of ASP: A Love Letter to the Early Web

There was something gloriously chaotic about learning ASP in the late 1990s and early 2000s. No bootcamps, no TypeScript configs, no package.json files with 47,000 dependencies. Just you, Notepad, and a burning desire to make things happen on the server side.

The Learning Landscape

The internet back then was a different place for developers. You didn’t have Stack Overflow. You didn’t have thousands of YouTube tutorials. What you had were a handful of websites run by enthusiasts who genuinely loved this stuff and wanted to share what they knew.

4GuysFromRolla.com was the crown jewel. Scott Mitchell and his crew weren’t just writing documentation, they were building a community. Their tutorials had personality. They’d walk you through creating a guest book, building a simple CMS, or implementing user authentication, and they’d do it in plain English with working code samples you could actually copy and paste. The site had this warm, collegial feeling, like you were learning from friends who happened to be a few steps ahead of you.

Then there were sites like ASPToday, which ran daily articles (an ambitious publishing schedule even by today’s standards), and the various forums scattered across the web. Dev Shed, WebDeveloper.com, and countless personal sites with names like “Bob’s ASP Corner” or “The ASP Zone.” These weren’t slick, venture-capital-backed platforms. They were hobbyist sites with garish color schemes, animated GIFs in the headers, and genuinely helpful people answering questions.

You’d bookmark dozens of these sites. You’d print out tutorials on actual paper. You’d keep a binder of code snippets because searching your hard drive was less reliable than just flipping through printed pages.

The Code Itself: Beautiful Chaos

ASP code from that era was wonderfully, terrifyingly free. There was no webpack, no linting, no CI/CD pipeline complaining about your coding standards. You just wrote code, uploaded it via FTP, and refreshed your browser.

A typical ASP page might look like this:

<%@ Language=VBScript %>
<html>
<head><title>My Cool Page</title></head>
<body>
<%
Dim conn, rs, sql
Set conn = Server.CreateObject("ADODB.Connection")
conn.Open "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" & Server.MapPath("database.mdb")

sql = "SELECT * FROM products WHERE category = '" & Request.QueryString("cat") & "'"
Set rs = conn.Execute(sql)

While Not rs.EOF
    Response.Write "<h3>" & rs("ProductName") & "</h3>"
    Response.Write "<p>Price: $" & rs("Price") & "</p>"
    rs.MoveNext
Wend

rs.Close
Set rs = Nothing
conn.Close
Set conn = Nothing
%>
</body>
</html>

Look at that. SQL injection vulnerability right there in the open. Presentation logic mixed with data access. No separation of concerns. Connection handling that would make a modern DBA weep. And yet… it worked. You could build something functional in an afternoon.

The coding style was utterly unstructured by today’s standards. Everything lived in one file if you wanted it to. You’d have a page that handled form submission, database queries, business logic, and HTML rendering all mixed together in a glorious spaghetti of <% %> tags and HTML.

People would create “libraries” by just including other ASP files:

<!--#include file="header.asp"-->
<!--#include file="database_functions.asp"-->
<!--#include file="user_functions.asp"-->

These includes would just dump their code right into your page. Variable scope? Session variables everywhere. Global state? Sure, why not. It was the Wild West, and we were all cowboys.

The Access Database Era

And then there were Access databases. Beautiful, maligned, wonderful Access databases.

For small projects, Access was perfect. You didn’t need to install MySQL. You didn’t need to configure anything. You just created a .mdb file in Microsoft Access, designed your tables with the GUI (relationships and all), and boom, you had a database. Upload it to your web server, point your connection string at it, and you were in business.

conn.Open "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" & Server.MapPath("mydb.mdb")

That was it. No credentials, no server configuration, no port numbers to remember. The database was just a file sitting in your web directory (hopefully in a folder outside the web root, but let’s be honest, not always).

Access databases could handle thousands of records just fine. For a small business website, a personal blog, a community forum with a few hundred users, they were absolutely adequate. They got a bad reputation because people would try to use them for high-traffic applications, and yes, they’d fall over. But for what they were designed for, they were brilliant.

The Access interface itself was approachable. You could design forms, create queries visually, build simple reports. Non-programmers could maintain the data. It was democratic in a way that PostgreSQL command-line tools could never be.

The Speed of Development

Here’s the thing nobody talks about when they reminisce about the old days: you could build something useful incredibly fast.

Need a contact form that saves to a database? Thirty minutes, maybe an hour if you wanted it to look nice. Need a simple content management system? A weekend project. Want to add user authentication to your site? Copy some code from 4GuysFromRolla, modify it for your needs, done.

There was no ceremony. No setting up Docker containers, no configuring webpack, no deciding between seventeen different JavaScript frameworks. You opened Notepad (or if you were fancy, HomeSite or Dreamweaver), wrote some code, saved it as .asp, uploaded it, and you were live.

The feedback loop was immediate. Change code, hit F5, see results. No build step. No compilation. No waiting for hot module replacement to kick in. Just instant gratification.

The Community Spirit

The ASP community had something special. It was small enough that you’d recognize the same names helping people on different forums. There was no snark, no “let me Google that for you” responses. People genuinely wanted to help.

When someone posted a question, they’d often include their entire code file. Not a minimal reproducible example, just all 300 lines of their product catalog page. And people would actually read through it and help. They’d spot the error on line 187 where you forgot to set an object to Nothing, or notice that you were opening a database connection inside a loop.

There were personalities. The “ASP gurus” who seemed to know everything. The newcomers asking basic questions. The intermediate folks helping out while still learning themselves. It felt like a real community, not just a Q&A marketplace.

What We’ve Gained

Today’s web development landscape is objectively better in many ways. The tooling is incredible. TypeScript catches errors before they reach production. Modern frameworks handle state management in ways that make sense. Package managers mean you don’t have to reinvent the wheel for every common task.

Security is taken seriously now. We have HTTPS everywhere, proper encryption, frameworks that sanitize inputs by default. The SQL injection vulnerability in that code sample above would never make it past a code review today.

The databases are better. PostgreSQL, MySQL, SQL Server, they’re all powerful, reliable, and can scale to millions of records. ORMs handle the tedious parts of data access. Migrations track schema changes.

Development environments are sophisticated. VS Code with its extensions, IntelliSense, debugging tools, Git integration. The modern developer has superpowers compared to someone in 1999 with Notepad and an FTP client.

Testing is built into the workflow. CI/CD pipelines catch issues before deployment. Monitoring tools tell you when things break. The entire ecosystem around professional software development has matured enormously.

What We’ve Lost

But we’ve also lost some things, and not all the changes are pure progress.

We’ve lost simplicity. A new developer today faces an overwhelming landscape. Which framework should they learn? React? Vue? Svelte? What about the backend? Node? Python? Go? What about databases, deployment, cloud providers, containerization? The barrier to entry is so much higher now.

We’ve lost immediacy. The edit-save-refresh cycle has been replaced with build processes, transpilation, bundling. Yes, hot reload helps, but there’s still something between you and the metal. You can’t just FTP a file and see your changes live in seconds.

We’ve lost that scrappy, make-it-work attitude. Today’s best practices are genuinely better practices, but they also add ceremony. You’re supposed to write tests first. You’re supposed to separate concerns. You’re supposed to use dependency injection. These are all good ideas, but they slow you down when you just want to make something.

We’ve lost some of the community intimacy. Stack Overflow is fantastic, but it’s transactional. You post a question, someone answers it, you move on. The old forums had ongoing relationships. You’d see the same people week after week. You’d graduate from asking questions to answering them. It felt like growth.

We’ve lost the personal websites. Those individual developers who’d post tutorials on their own domains, with their own personality shining through. Now everything is on Medium, Dev.to, or corporate blogs. It’s more polished but less personal.

What Still Rocks from the Early Days

Some things from the ASP era deserve recognition, even reverence:

The directness of it all. You could look at an ASP page and understand the entire flow from request to response. No magic, no framework abstractions, just code executing from top to bottom. That transparency was pedagogically valuable.

The completeness of simple solutions. An Access database application was completely self-contained. You could zip up a folder and hand someone a working application. Try doing that with a modern web app and its node_modules folder, database migrations, environment variables, and cloud dependencies.

The low stakes experimentation. You could try things without committing to an entire ecosystem. Want to experiment with e-commerce? Just add a shopping cart table to your Access database and write some checkout code. No need to evaluate payment processing SDKs, comply with PCI requirements from day one, or set up a secure key management system. You could prototype fast and learn.

The view source culture. You could right-click on any page, view source, and see how it was built. Yes, you only saw the HTML output, not the ASP code, but people would share their actual code liberally. Today’s minified, bundled, compiled output is technically superior but pedagogically opaque.

The 80/20 solutions. Access databases and simple ASP code got you 80% of the way there for probably 5% of the effort. Yes, the last 20% was hard, but most projects didn’t need that last 20%. Most projects just needed to work well enough.

The Verdict

Is everything better today? In terms of capability, scalability, security, and professional software engineering practices, absolutely yes. Modern web development produces better software, more reliably, at greater scale.

But we’ve paid a price in accessibility and joy. The early web was a place where a curious teenager could view source, learn some ASP from 4GuysFromRolla, spin up an Access database, and build something real. That ladder into programming still exists, but it’s gotten longer and more complex.

The truth is, we need both. We need the rigor and sophistication of modern development for the applications that run our world. But we also need to preserve some of that early-web spirit: the idea that someone with curiosity and determination can build something without asking permission, without a computer science degree, without mastering a dozen tools first.

Maybe that’s what still rocks most about those early days. Not the specific technologies, which were genuinely limited in many ways, but the feeling that the web was this open, malleable space where anyone could make something. Where the barrier between user and creator was permeable. Where “I wonder if I could build that” was answered by opening a text editor and just trying.

The 4GuysFromRolla.com crew and all those other tutorial writers weren’t just teaching ASP syntax. They were teaching a mindset: that the web is yours to shape, that building things is learnable, that imperfect working code beats perfect planning every time.

That spirit, more than any specific technology, is what deserves to survive from those early, chaotic, wonderful days.

How I Found a Hidden Bug in Wagtail-Localize

Wagtail Admin Explorer - Showing nothing

I am working on a GSoC proposal for Wagtail CMS. As part of my preparation, I set up a multilingual demo site using wagtail-localize. I learned a lot about how Wagtail handles translations and found a bug along the way.

What I Was Doing

I extended Wagtail’s Bakery Demo with three languages: English, Spanish, and French. For Spanish, I translated pages one by one. For French, I used the “Include subtree” option to translate an entire section at once.

The Spanish pages worked fine, but the French pages had a strange problem.

What Went Wrong

After translating the French subtree, I could not see any child pages in the admin panel. The pages existed in the database. They had content. But the admin explorer showed nothing under the French homepage.

Wagtail Admin Explorer - Showing nothing

I got “Page not found” errors when I visited the child page URLs directly, even though the pages exist.

How I Diagnosed It

I wrote a small script to check the page tree data. Every Wagtail page stores a field called numchild. This tells the admin how many children a page has. If numchild is zero, the admin skips the database query to find children.

The French homepage had numchild = 0, even though it had several child pages. The “Include subtree” translation made the child pages but didn’t update the parent’s counter.

This happens because wagtail-localize uses django-treebeard to manage the page tree. When it makes many pages at once during subtree translation, it skips updating numchild.

How I Fixed It

The fix was simple. I ran one command in the Django shell:

from wagtail.models import Page
Page.fix_tree()

This rebuilt the tree metadata. All French child pages showed up in the admin and loaded fine on the frontend.

Wagtail Admin Explorer - Showing all French child pages

What I Learned

  1. Translation tools can have subtle data bugs. The pages were created correctly, but the metadata was wrong. This kind of bug is hard to spot because everything looks fine until you check the admin explorer.

  2. numchild is critical for Wagtail’s page tree. It controls whether the admin even tries to find child pages. A wrong value makes pages invisible.

  3. Bulk operations need extra care. When you create many pages at once, update all related metadata too. It’s not enough to just update the pages.

I reported this as issue #773 in wagtail-localize. The bug is still open and needs a permanent fix in the package.

If you use wagtail-localize’s subtree translation feature, watch out for this. Running Page.fix_tree() is a good workaround until the fix is merged.

Check out my multilingual demo: sushanthaaa.pythonanywhere.com | Source code

I built a system that verifies if security decisions were tampered with (offline)

I built a system that verifies whether a security decision was altered or not.

The idea is simple:
given a decision file and its evidence bundle, the system can independently verify its integrity.

It produces:

  • VERIFIED (intact)
  • or TAMPERED (modified)

Key characteristics:

  • works fully offline (no cloud, no API)
  • deterministic (same input always gives the same result)
  • no need to trust the original system

Why this matters:
teams can run security scans, but often cannot prove why a decision was made after the fact.

This focuses only on verification and proof.

I created a simple demo:

  • run verify
  • modify one value
  • see it fail

I’m interested to know:
would something like this be useful in audits, compliance, or incident investigations?

If relevant, feel free to reach out:
[email protected]

My DDoS Protection Looked Solid Until I Actually Tested It” published

A few months ago I got tired of operating on blind confidence. My server had Cloudflare in front of it, rate limiting configured, fail2ban running — all the standard stuff. I’d set it up following guides, felt like I knew what I was doing, and proceeded to never actually verify any of it worked.

Then a colleague asked me a simple question: when did you last test this under real load? Not synthetic one-machine benchmarks — actual load, from multiple sources, the kind of traffic that looks like a real flood?

I had no good answer. So I went and found out.

The testing part took an afternoon. The aftermath took two weeks and left me with more questions than answers.

The Setup I Was “Confident” About

Let me describe what I was running, because I think it’s a pretty typical production setup:

  • VPS on DigitalOcean, nginx as the web server
  • Cloudflare proxy in front, orange cloud enabled
  • Rate limiting rules in nginx
  • fail2ban watching logs and banning repeat offenders
  • Standard WAF ruleset
  • Redis caching on the busy pages

On paper: reasonable. In practice: never stress tested under anything resembling a real attack. I had configuration. I did not have evidence.

Why a Service, Not a DIY Tool

My first thought was to just spin up a cloud VM and hammer my own server with ab or wrk.

The problem: that’s one IP. All the load coming from a single source is not what real floods look like, and it immediately trips your per-IP rate limiting. You end up testing whether you can block one very angry user, which is a much easier problem than the actual one.

I ended up using floodlab.cx because they do distributed testing — traffic from multiple source IPs, configurable patterns, and crucially, the ability to simulate traffic that looks like real users rather than obvious bot cruft. That last part was specifically what I wanted to see.

Setup was simple enough. Pointed it at my domain, set the parameters, started the test.

Watching My Server Die in Real Time

I had a coffee, opened my monitoring dashboard, and watched the test run.

The first few minutes were anticlimactic. Traffic ramping up, response times normal, server not breaking a sweat. I started to feel like maybe I’d been worrying about nothing.

Then things got interesting.

Around the 5-minute mark, response times started climbing. Not dramatically — just enough to notice. CPU ticking up. Database connections getting busier.

By 8,000 req/s, the site was noticeably sluggish. By 12,000, the database connection pool was exhausted and the site was returning errors to everyone. Load balancer healthy, nginx running, server not crashed — but every request failing because there was nothing left to serve them.

Four minutes from “feels a bit slow” to “functionally offline.”

Here’s what made it worse: my monitoring dashboard was green the entire time. CPU elevated but not critical. Memory fine. No firewall alerts. Cloudflare reporting normal operations. From the perspective of every metric I was watching, nothing was wrong. Just… a lot of traffic, all of it looking completely legitimate.

The only thing I wasn’t watching was whether users were actually getting responses. They weren’t.

Two Weeks of Trying to Fix It

This is the part I’m a bit embarrassed about.

I went into this thinking: find the weak spots, tune the config, done. What I found out is that certain types of floods are genuinely hard to defend against without degrading the experience for real users — because what makes them hard to block is exactly what makes them hard to distinguish from real traffic.

I won’t go into the mechanics of how these attacks work, because that’s not really the point. The point is: from my server’s perspective, it was just getting a lot of visitors. Lots of different people, browsing normally. Nothing obviously malicious to catch.

So every defensive approach I tried ran into the same wall:

Tightening rate limits — either the limits were high enough that the flood stayed under them, or they were low enough that real users also started hitting them during busy periods.

WAF rules — there was nothing anomalous to write a rule against. The requests looked fine.

fail2ban tuning — same problem. No individual source was misbehaving badly enough to trigger a ban. The issue was aggregate volume, not any single bad actor.

Cloudflare configuration — I went through everything. Bot fight mode, various security level settings, custom rules. Each test run came back with roughly the same result.

I re-ran the floodlab test after each attempt. The numbers barely moved.

The One Thing That Actually Stopped It

After two weeks, the only configuration change that reliably stopped the flood was enabling Cloudflare’s “Under Attack Mode” — a challenge page that every visitor has to pass before reaching the site.

It worked. Traffic stopped reaching my server.

It also:

  • Added a noticeable delay to every first page load for real users
  • Broke my mobile app’s API calls entirely
  • Broke all my monitoring and uptime checks
  • Broke third-party webhooks that POST to my backend
  • Is explicitly designed for use during an active attack, not as a permanent setting

So my “solution” was to make my site worse for everyone, permanently, to defend against an attack that wasn’t currently happening. The moment I turn it off, I’m back to square one.

That felt less like a fix and more like a different kind of self-inflicted problem.

What Actually Helped (Without Breaking Everything)

Not all was lost. Some changes genuinely moved the needle:

Aggressive caching on read-heavy pages. If the response comes from cache, the database never gets involved. This pushed my breaking point from around 12,000 req/s to closer to 18,000. Not a solution, but meaningful headroom.

Per-endpoint rate limiting instead of global. I had one rate limit for everything. Some endpoints are cheap; some are expensive. Treating them the same means your expensive endpoints are significantly under-protected.

Database connection pool sized for peak, not average. Mine was tuned for normal traffic. Under load it ran out almost immediately. Increasing it gave better survivability during spikes, even if it didn’t stop the degradation entirely.

Actually monitoring user-facing error rates. The big embarrassing discovery: I was watching server metrics, not user outcomes. I added synthetic checks that make real HTTP requests and verify they get successful responses. Now if the site is returning errors, something actually alerts.

What I Took Away From All This

“Config exists” and “config works” are different things. I had protection configured. I’d never verified it actually protected against anything realistic. These are not the same state.

Cloudflare is not a guarantee. It’s genuinely useful, but if your origin server falls over on its own before Cloudflare steps in, that’s still downtime. The proxy doesn’t replace a resilient backend.

Monitoring the wrong things means you get no warning. I had decent monitoring that told me nothing useful during the test. Adding user-facing synthetic checks was the highest-value change I made.

Some problems don’t have cheap clean solutions. Two weeks of work and the honest answer is: defending against sophisticated floods at a serious scale, without breaking the site for real users, requires either significant engineering investment or enterprise-level tooling. There’s no nginx config that makes this go away.

Then Floodlab Actually Helped Me Fix It

After a week of going in circles on my own, I reached out to the floodlab team. Wasn’t really expecting much — figured they’re a testing service, not a consultancy.

Turned out to be the most useful conversation I had throughout this whole process.

They looked at the test results with me and walked me through what was actually happening at each stage of the degradation. Not generic advice — specific, based on the actual numbers from my tests. Where the bottleneck was, why certain mitigations weren’t working, what the realistic options were given my stack.

The short version of what they helped me put together:

First, properly structured rate limiting across multiple dimensions — not just per-IP, but combining source, endpoint, and request frequency in a way that raises the collective cost of a flood without tripping on normal user behavior. The implementation details took some tuning, but the logic they explained made sense of why my previous attempts kept failing.

Second, a tiered response configuration in Cloudflare that starts mild and escalates automatically based on error rates — so I’m not choosing between “do nothing” and “Under Attack Mode breaks everything.” There are intermediate steps that add friction progressively.

Third, some caching and connection pooling changes specific to my app’s traffic patterns that gave the backend meaningful extra headroom before hitting the database wall.

I re-ran the test after implementing all of it. Not magically bulletproof — I don’t think that exists at my scale without serious spend — but the site stayed functional at load levels that had previously killed it within minutes. The degradation was gradual rather than a cliff. And when things did get slow, my monitoring actually fired alerts this time.

For what it’s worth: I didn’t expect a testing service to be the ones who helped me get the protection working. But they clearly know what the attacks look like from the inside, which turns out to be pretty useful context when you’re trying to defend against them.

Was the Test Worth Doing?

Absolutely — and I’d recommend it to anyone running production infrastructure who hasn’t done this.

Floodlab.cx gave me a clear, realistic picture of where my server actually breaks, under conditions that resemble real attacks far more than any single-machine load test does. The dashboard is clear, the reporting is detailed, and watching everything fall over in real time while your monitoring stays green is exactly the kind of uncomfortable lesson that actually changes how you think about your setup.

I went in with misplaced confidence and came out with a more accurate picture of where I stood. That’s worth something, even when what you learn isn’t flattering.

My server is more resilient than it was. It’s more resilient than I thought it was before the test — because now I’ve actually verified that, rather than just assuming. Both things feel good to know.

If you’re at the “my protection is probably fine but I’ve never actually tested it” stage — that’s where I was. Running the test is the uncomfortable but necessary first step. And if you end up where I was after the test, scratching your head at the results — reaching out to the floodlab.cx team directly was genuinely worth it.

Solana MEV Defense in 2026: How Sandwich Bots Extracted $500M — And the 6 Protocol-Level Defenses That Actually Work

Sandwich bots extracted between $370M and $500M from Solana users over the past 16 months. One single program — vpeNALD…Noax38b — accounts for nearly half of all sandwich attacks on the network, executing 51,600 transactions daily with an 88.9% success rate and pocketing ~2,200 SOL ($450K) per day.

And it’s getting worse. Wide (multi-slot) sandwich attacks now account for 93% of all sandwich activity, extracting over 529,000 SOL in the past year alone. These attacks span multiple validator slots, making them nearly invisible to traditional detection methods.

This article breaks down exactly how Solana sandwich attacks work in 2026, why Firedancer’s arrival changes the MEV landscape, and the 6 protocol-level defenses that actually protect your users’ trades.

How Solana Sandwich Attacks Actually Work

Unlike Ethereum’s mempool-based MEV, Solana sandwich attacks exploit a different architectural property: the relationship between validators and transaction ordering within blocks.

The Classic Sandwich

Block N:
  tx[0]: Attacker buys TOKEN (front-run)
  tx[1]: Victim swaps SOL → TOKEN (user's trade)
  tx[2]: Attacker sells TOKEN (back-run)

The attacker’s front-run pushes the price up. The victim buys at an inflated price. The attacker’s back-run captures the difference. The victim gets fewer tokens than they should.

The Wide Sandwich (93% of attacks in 2025-2026)

Slot N (Attacker-Controlled Validator):
  tx[last]: Attacker buys TOKEN (front-run)

Slot N+1 (Any Validator):
  tx[mid]: Victim's swap executes at inflated price

Slot N+2 (Attacker-Controlled Validator):
  tx[0]: Attacker sells TOKEN (back-run)

Wide sandwiches split the front-run and back-run across different slots. This has two advantages for attackers:

  1. Harder to detect — the three transactions don’t appear in the same block
  2. Validator collusion — attackers only need to control one slot to front-run, then wait for the natural price impact

The Jito Bundle Attack Vector

With 92% of Solana validators running the Jito-Solana client, the Jito block engine has become the primary MEV marketplace. Searchers submit bundles — atomic groups of transactions — with tips to validators for priority inclusion.

# Simplified searcher logic
def sandwich_opportunity(pending_tx):
    """Detect and exploit a sandwichable swap."""
    token_in = pending_tx.token_in
    token_out = pending_tx.token_out
    amount = pending_tx.amount
    slippage = pending_tx.max_slippage  # Usually 0.5-3%

    # Calculate if the price impact from front-running
    # stays within victim's slippage tolerance
    price_impact = estimate_impact(token_out, amount)

    if price_impact < slippage:
        # Profitable sandwich possible
        front_run = build_buy_tx(token_out, optimal_amount)
        back_run = build_sell_tx(token_out, optimal_amount)

        bundle = JitoBundle([front_run, pending_tx, back_run])
        bundle.tip = calculate_optimal_tip(expected_profit)
        submit_bundle(bundle)

The critical insight: the victim’s slippage tolerance is their vulnerability. A 1% slippage setting on a $10,000 swap means an attacker can extract up to $100. Across millions of daily transactions, this adds up fast.

Why Firedancer Changes Everything (And Not How You’d Expect)

Firedancer, Jump Crypto’s independently developed Solana validator client, is rolling out through 2026 with dramatic performance improvements — up to 1M TPS in testing. But its MEV implications are complex:

Shorter Block Times = Shorter Attack Windows

Firedancer’s optimized block packing means transactions spend less time in any “pending” state. This theoretically shrinks the window for sandwich detection and execution.

But: Firedancer also supports a “Revenue Mode” that explicitly prioritizes MEV capture. Validators running Firedancer in revenue mode may actually be more efficient at extracting MEV, not less.

More Transactions = More Opportunities

Higher throughput means more swaps per block, which means more sandwich opportunities. The raw volume of extractable value goes up even as individual attack windows shrink.

The Verification Lag Problem

Firedancer’s dynamic block sizing and the “Skip-Vote Phenomenon” create verification lag — a gap between when a transaction is included in a block and when it’s confirmed by the network. For time-sensitive operations like DEX swaps, this lag can be exploited:

// VULNERABLE: No slot-awareness in price check
pub fn execute_swap(ctx: Context<Swap>, amount: u64, min_out: u64) -> Result<()> {
    let price = get_oracle_price(&ctx.accounts.oracle)?;
    // Price might be stale by 1-2 slots due to verification lag
    let output = calculate_output(amount, price);
    require!(output >= min_out, SwapError::SlippageExceeded);
    // ... execute
    Ok(())
}

// FIXED: Explicit slot freshness check
pub fn execute_swap(ctx: Context<Swap>, amount: u64, min_out: u64) -> Result<()> {
    let clock = Clock::get()?;
    let oracle_data = get_oracle_data(&ctx.accounts.oracle)?;

    // Reject if oracle update is more than 2 slots old
    require!(
        clock.slot - oracle_data.last_update_slot <= 2,
        SwapError::StaleOracle
    );

    let output = calculate_output(amount, oracle_data.price);
    require!(output >= min_out, SwapError::SlippageExceeded);
    // ... execute
    Ok(())
}

Defense #1: Jito’s dontfront — First-Index Guarantee

Jito’s dontfront feature ensures your transaction is placed at index 0 within any bundle, preventing front-running within the same block.

How to Use It

import { Connection, Transaction } from '@solana/web3.js';

// Submit through Jito's block engine with dontfront
const JITO_BLOCK_ENGINE = 'https://mainnet.block-engine.jito.wtf/api/v1/transactions';

async function submitProtectedTx(tx: Transaction, keypair: Keypair) {
    const serialized = tx.serialize();

    const response = await fetch(JITO_BLOCK_ENGINE, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            jsonrpc: '2.0',
            id: 1,
            method: 'sendTransaction',
            params: [
                Buffer.from(serialized).toString('base64'),
                {
                    encoding: 'base64',
                    // dontfront: transaction gets index 0 priority
                    skipPreflight: true,
                    maxRetries: 3
                }
            ]
        })
    });

    return response.json();
}

Limitations

  • Only protects within a single block — wide sandwiches across slots are unaffected
  • Requires submitting through Jito’s endpoint
  • Costs additional tips for priority placement
  • 92% validator adoption means 8% of slots have no Jito protection

Defense #2: Private Transaction Relays

Private relays encrypt transactions until execution, eliminating the observation window that sandwich bots need.

Astralane’s Encrypted Channel

// Submit through Astralane's private relay
const ASTRALANE_RELAY = 'https://relay.astralane.io/v1/submit';

async function submitPrivate(tx: Transaction, keypair: Keypair) {
    const signed = await signTransaction(tx, keypair);

    // Transaction is encrypted in transit
    // Only becomes visible at execution time
    const response = await fetch(ASTRALANE_RELAY, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-API-Key': process.env.ASTRALANE_KEY
        },
        body: JSON.stringify({
            transaction: Buffer.from(signed.serialize()).toString('base64'),
            options: {
                mevProtection: true,
                maxRetries: 5,
                priorityFee: 'auto'
            }
        })
    });

    return response.json();
}

BloXroute’s Leader-Aware Routing

BloXroute takes a different approach — scoring upcoming slot leaders for sandwich risk and routing around dangerous validators:

// BloXroute scores validators and avoids known sandwich-colluding ones
const BLOXROUTE_API = 'https://solana.trader-api.bloxroute.com/v1';

async function submitWithLeaderAwareness(tx: Transaction) {
    const response = await fetch(`${BLOXROUTE_API}/submit`, {
        method: 'POST',
        headers: {
            'Authorization': process.env.BLOXROUTE_AUTH,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            transaction: serializeTx(tx),
            submitStrategy: {
                // Route through multiple channels for redundancy
                multipath: true,
                // Score leaders and avoid high-risk validators
                leaderAwareMevProtection: true,
                // Use Jito, Paladin, and bloXroute simultaneously
                channels: ['jito', 'paladin', 'bloxroute']
            }
        })
    });

    return response.json();
}

Defense #3: Program-Level Slippage with TWAP Validation

The most robust defense lives in the smart contract itself. Instead of trusting user-provided min_out alone, validate against a TWAP (Time-Weighted Average Price):

use anchor_lang::prelude::*;

#[program]
pub mod protected_swap {
    use super::*;

    pub fn swap_with_twap_guard(
        ctx: Context<SwapGuarded>,
        amount_in: u64,
        min_amount_out: u64,
        max_price_deviation_bps: u16,  // e.g., 100 = 1%
    ) -> Result<()> {
        let oracle = &ctx.accounts.oracle;
        let clock = Clock::get()?;

        // 1. Get spot price
        let spot_price = oracle.get_spot_price()?;

        // 2. Get TWAP over last 30 seconds
        let twap_price = oracle.get_twap(
            clock.unix_timestamp - 30,
            clock.unix_timestamp
        )?;

        // 3. Reject if spot deviates from TWAP beyond threshold
        // This catches sandwich-inflated prices
        let deviation = if spot_price > twap_price {
            ((spot_price - twap_price) * 10_000) / twap_price
        } else {
            ((twap_price - spot_price) * 10_000) / twap_price
        };

        require!(
            deviation <= max_price_deviation_bps as u64,
            SwapError::PriceDeviationTooHigh
        );

        // 4. Execute swap with validated price
        let output = execute_swap_internal(
            &ctx.accounts.pool,
            amount_in,
            spot_price
        )?;

        require!(output >= min_amount_out, SwapError::SlippageExceeded);

        // 5. Post-swap sanity check
        let post_price = ctx.accounts.pool.get_current_price()?;
        let impact = ((spot_price - post_price) * 10_000) / spot_price;
        require!(
            impact <= max_price_deviation_bps as u64 * 2,
            SwapError::ExcessivePriceImpact
        );

        Ok(())
    }
}

#[derive(Accounts)]
pub struct SwapGuarded<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(mut)]
    pub pool: Account<'info, LiquidityPool>,
    /// CHECK: Validated by oracle program CPI
    pub oracle: AccountInfo<'info>,
    pub oracle_program: Program<'info, PythOracle>,
}

Why this works: A sandwich attack temporarily inflates the spot price. But the TWAP — averaged over 30 seconds of trading — won’t spike from a single front-run transaction. If spot diverges from TWAP by more than 1%, the swap reverts, and the attacker’s front-run transaction costs them gas with zero profit.

Defense #4: Commit-Reveal Swap Pattern

Split swaps into two transactions to make front-running impossible:

#[program]
pub mod commit_reveal_swap {
    use super::*;

    /// Phase 1: Commit — user locks funds and submits hash of swap params
    pub fn commit_swap(
        ctx: Context<CommitSwap>,
        commitment_hash: [u8; 32],  // hash(amount, min_out, nonce)
        deposit_amount: u64,
    ) -> Result<()> {
        let commitment = &mut ctx.accounts.commitment;
        let clock = Clock::get()?;

        // Lock funds in escrow
        transfer_tokens(
            &ctx.accounts.user_token_account,
            &ctx.accounts.escrow,
            deposit_amount,
            &ctx.accounts.user,
        )?;

        commitment.user = ctx.accounts.user.key();
        commitment.hash = commitment_hash;
        commitment.amount = deposit_amount;
        commitment.slot = clock.slot;
        commitment.executed = false;

        Ok(())
    }

    /// Phase 2: Reveal — user reveals params, swap executes
    pub fn reveal_and_swap(
        ctx: Context<RevealSwap>,
        amount: u64,
        min_out: u64,
        nonce: u64,
    ) -> Result<()> {
        let commitment = &mut ctx.accounts.commitment;
        let clock = Clock::get()?;

        // Must reveal within 10 slots of commit
        require!(
            clock.slot <= commitment.slot + 10,
            SwapError::CommitmentExpired
        );
        // Must wait at least 2 slots (prevents same-block reveal)
        require!(
            clock.slot >= commitment.slot + 2,
            SwapError::RevealTooEarly
        );

        // Verify commitment
        let expected_hash = hash(&[
            &amount.to_le_bytes(),
            &min_out.to_le_bytes(),
            &nonce.to_le_bytes(),
        ]);
        require!(
            expected_hash.to_bytes() == commitment.hash,
            SwapError::InvalidReveal
        );

        // Execute swap from escrow
        let output = execute_swap_from_escrow(
            &ctx.accounts.escrow,
            &ctx.accounts.pool,
            amount,
            min_out,
        )?;

        commitment.executed = true;

        Ok(())
    }
}

Trade-off: Commit-reveal adds latency (~1-2 seconds for 2-slot wait) and requires two transactions. Best for large swaps where the MEV extraction would exceed the extra gas cost.

Defense #5: Dynamic Slippage Based on Trade Size

Most wallets and DEX frontends use a static slippage tolerance (0.5%, 1%, etc.). This is a gift to sandwich bots — they know exactly how much they can extract. Dynamic slippage scales the tolerance based on the trade’s price impact:

// Frontend SDK for dynamic slippage calculation
function calculateDynamicSlippage(
    tradeSize: number,
    poolLiquidity: number,
    recentVolatility: number,  // 1-hour price volatility
    blockCongestion: number    // current slot utilization %
): number {
    // Base: expected price impact from trade size
    const expectedImpact = (tradeSize / poolLiquidity) * 100;

    // Volatility buffer: wider in volatile markets
    const volatilityBuffer = recentVolatility * 0.5;

    // Congestion premium: tighter when network is calm
    const congestionMultiplier = blockCongestion > 80 ? 1.5 : 1.0;

    // Dynamic slippage = impact + buffer, but never more than 2x impact
    const slippage = Math.min(
        (expectedImpact + volatilityBuffer) * congestionMultiplier,
        expectedImpact * 2
    );

    // Floor at 0.1%, cap at 3%
    return Math.max(0.1, Math.min(slippage, 3.0));
}

// Example results:
// $100 swap in $10M pool → 0.1% slippage (floor)
// $10K swap in $1M pool → 1.0% slippage (standard impact)
// $100K swap in $1M pool → 3.0% slippage (cap, warn user)

Why this works: A $100 swap gets 0.1% slippage — only $0.10 extractable, less than the attacker’s gas cost. A $100,000 swap still gets 3% maximum, but the user is warned and can use commit-reveal for additional protection.

Defense #6: On-Chain Sandwich Detection Circuit Breaker

For protocols managing significant TVL, implement an on-chain circuit breaker that detects sandwich patterns and pauses trading:

#[account]
pub struct TradingState {
    pub last_trade_direction: TradeDirection,  // Buy or Sell
    pub consecutive_reversals: u8,
    pub last_trade_slot: u64,
    pub last_trade_size: u64,
    pub circuit_breaker_active: bool,
    pub circuit_breaker_until: u64,
}

pub fn check_sandwich_pattern(
    state: &mut TradingState,
    current_direction: TradeDirection,
    current_slot: u64,
    trade_size: u64,
) -> Result<()> {
    // Detect rapid buy-sell-buy or sell-buy-sell in same slot
    if current_slot == state.last_trade_slot 
        && current_direction != state.last_trade_direction 
    {
        state.consecutive_reversals += 1;

        // 3 reversals in same slot = likely sandwich
        if state.consecutive_reversals >= 3 {
            state.circuit_breaker_active = true;
            state.circuit_breaker_until = current_slot + 10;

            emit!(SandwichDetected {
                slot: current_slot,
                reversals: state.consecutive_reversals,
            });

            return Err(SwapError::CircuitBreakerTripped.into());
        }
    } else if current_slot > state.last_trade_slot {
        // New slot, reset counter
        state.consecutive_reversals = 0;
    }

    // Check if circuit breaker is still active
    if state.circuit_breaker_active && current_slot < state.circuit_breaker_until {
        return Err(SwapError::CircuitBreakerActive.into());
    } else {
        state.circuit_breaker_active = false;
    }

    state.last_trade_direction = current_direction;
    state.last_trade_slot = current_slot;
    state.last_trade_size = trade_size;

    Ok(())
}

The Defense Matrix: Which Protection for Which Scenario

Defense Protects Against Latency Cost Best For
Jito dontfront Same-block sandwich None All trades via Jito
Private relays Observation-based attacks ~50ms High-value trades
TWAP validation Price manipulation None (on-chain) Protocol-level AMMs
Commit-reveal All sandwich types 1-2 seconds Trades > $10K
Dynamic slippage Over-extraction None Frontend/wallet UX
Circuit breaker Repeated attacks Pauses trading High-TVL pools

Layered Defense: The Recommended Stack

For maximum protection, layer multiple defenses:

User Intent (Frontend)
    │
    ├─ Dynamic slippage calculation
    │
    ▼
Transaction Submission
    │
    ├─ Private relay (Astralane/BloXroute)
    ├─ Jito dontfront for bundle protection
    │
    ▼
On-Chain Execution
    │
    ├─ TWAP deviation check
    ├─ Sandwich pattern detection
    ├─ Circuit breaker
    │
    ▼
Post-Trade Verification
    │
    └─ Price impact sanity check

No single defense is sufficient. Jito’s dontfront doesn’t stop wide sandwiches. Private relays don’t help if the validator itself is malicious. TWAP checks don’t prevent extraction below the deviation threshold. Layer them.

The Firedancer Forecast: What Changes in Late 2026

When Firedancer reaches majority adoption:

  1. Block times shrink → Less time for searchers to analyze and submit bundles
  2. Throughput increases → More transactions to sandwich, but also more noise
  3. Revenue Mode validators → MEV extraction becomes more efficient, not less
  4. New attack surface → Different block scheduling between Firedancer and Jito-Solana creates arbitrage between validator implementations

My prediction: Total MEV extracted will increase with Firedancer, but per-transaction extraction will decrease. The winners will be protocols with on-chain defenses (TWAP checks, circuit breakers) that work regardless of the validator client.

Action Items

  1. Today: Check your DEX frontend’s default slippage — if it’s static 0.5%, you’re donating to sandwich bots
  2. This week: Implement Jito dontfront or private relay submission for your protocol’s frontend
  3. Before Firedancer mainnet: Add TWAP deviation checks to any swap instruction in your programs
  4. For high-TVL protocols: Implement the commit-reveal pattern for large trades and circuit breakers for repeated attack detection

The $500M extracted from Solana users isn’t a technology failure — it’s a defense failure. Every tool in this article exists today. The only question is whether you’ll implement them before your users pay the tax.

This is part of the DeFi Security Research series. Previously: ZK Circuit Kill Chain, 5 Smart Contract Anti-Patterns.

Follow @ohmygod for weekly security research.

Building a RAG-Based Subsidy Matching System from Scratch with Python

What I Built

A RAG (Retrieval-Augmented Generation) system that helps Japanese small businesses find government subsidies. Users describe their business situation, and the system retrieves relevant subsidies from a vector database, then generates a detailed answer using Claude API.

Why RAG Instead of Just Using an LLM?

LLMs alone have three problems for this use case:

  1. Hallucination — They confidently make up subsidy details that don’t exist
  2. Stale data — Subsidy information updates frequently; LLM training data can’t keep up
  3. No sources — Users need to verify the information, but LLMs don’t cite where it came from

RAG solves all three by retrieving real data first, then passing it to the LLM as context. The LLM generates answers grounded in actual documents, not its training data.

Architecture

User Query
    │
    ▼
┌─────────────┐     ┌──────────────┐
│  Retriever  │────▶│  Generator   │
│ (embed +    │     │ (Claude API) │
│  search)    │     └──────┬───────┘
└──────┬──────┘            │
       │                   ▼
┌──────▼──────┐      AI Answer
│  ChromaDB   │      with sources
│ (Vector DB) │
└─────────────┘

The data ingestion pipeline:

JSON Data → Sentence Chunker → Embedding Model → ChromaDB

Tech Stack and Why

Layer Choice Why
Embedding multilingual-e5-large Best multilingual performance for free. Runs locally — no data leaves the machine
Vector DB ChromaDB Zero config. pip install and it works with local persistence
LLM Claude API Strong long-context handling and Japanese language quality
Frontend Streamlit Full UI in ~40 lines of Python

Why Not LangChain/LlamaIndex?

I intentionally built the pipeline from scratch first. Frameworks abstract away the internals, which is great for production but bad for learning. By writing each step manually — chunking, embedding, storing, retrieving, prompting — I understood exactly what each piece does and why it matters.

Key Implementation Details

1. Sentence-Boundary Chunking

The most impactful decision was how to split documents into chunks.

Naive approach (fixed-size split):

...eligible IT tools include accounting software, ord  ← cut mid-sentence
er management software, payment software...           ← next chunk

My approach (split at Japanese sentence boundaries 。):

sentences = text.split("。")
# Greedily pack sentences into chunks up to CHUNK_SIZE
# Overlap the tail for context continuity

This preserves semantic meaning in each chunk, which directly improves retrieval accuracy.

2. Embedding with Prefix

multilingual-e5 requires specific prefixes to distinguish between stored passages and search queries:

# When indexing documents
prefixed = [f"passage: {text}" for text in texts]

# When searching
query_embedding = model.encode(f"query: {user_query}")

Missing this prefix is a common mistake that silently degrades search quality.

3. Prompt Design for Grounded Answers

The generation prompt explicitly constrains the LLM:

prompt = """You are a Japanese subsidy advisor.
Answer based on the reference information below.
If information is insufficient, state that explicitly.
For critical details like deadlines and amounts, be precise."""

This reduces hallucination by telling the model to stick to the provided context.

Evaluation: Measuring What Matters

Building a RAG that “seems to work” isn’t enough. I created a test suite with 10 queries, each with an expected correct subsidy:

Query Expected Result Rank
“Want to introduce accounting software” Digital/AI Subsidy 1st
“Want to install new manufacturing equipment” Manufacturing Subsidy 2nd
“Small shop wanting to attract customers via flyers” Small Business Sustainability Subsidy 1st
“Restaurant wanting to start EC business” New Business Advancement Subsidy 3rd
“Want to convert part-timers to full-time” Career Up Grant 1st
… … …

Results:

Metric Score
Hit Rate @5 100% (10/10)
MRR @5 0.817
  • Hit Rate @5: The correct answer appeared in the top 5 results for every query
  • MRR (Mean Reciprocal Rank): On average, the correct answer ranked between 1st and 2nd place

Why These Metrics?

  • Hit Rate tells you “can the system find the right answer at all?”
  • MRR tells you “how quickly does it find it?” (1st place = 1.0, 2nd = 0.5, 3rd = 0.33)

Both are standard IR (Information Retrieval) metrics. Having these numbers lets you objectively compare different chunking strategies, embedding models, or retrieval parameters.

What I Learned

1. Chunking strategy matters more than the embedding model

Switching from fixed-size to sentence-boundary chunking had a bigger impact on retrieval quality than I expected. The embedding model can only work with what it’s given — if chunks are semantically broken, even the best model can’t fix that.

2. Evaluation should come before optimization

Building the evaluation script early gave me a baseline to measure improvements against. Without it, I would have been tuning blindly.

3. Start simple, verify, then add complexity

The entire system is ~200 lines of Python (excluding tests). No frameworks, no complex abstractions. This made debugging straightforward — when something went wrong, there were very few places to look.

What’s Next

  • Reranker: Add a cross-encoder reranker to improve ranking quality (especially for Q4 and Q9 which ranked 3rd)
  • Hybrid search: Combine vector similarity with keyword matching (BM25) for better recall
  • More data: Scale from 15 to 100+ subsidies with automated collection
  • Production frontend: Migrate from Streamlit to Next.js + FastAPI for a proper web application

If you’re building a RAG system for the first time, I’d recommend the same approach: build from scratch first, measure with real metrics, then decide if you need a framework. The understanding you gain is worth far more than the time saved by a framework.

Spin Has a Free API: Build WebAssembly Microservices in 30 Seconds

Docker containers start in seconds. WebAssembly modules start in milliseconds. Spin makes building them as easy as Express.

What Is Spin?

Spin by Fermyon is a framework for building WebAssembly-based microservices. Write in Rust, Go, Python, JavaScript, or C# — compile to Wasm — deploy anywhere.

spin new -t http-rust my-app
cd my-app
spin build
spin up
# HTTP server running on http://localhost:3000

The Code

// Rust
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(r#"{"message": "Hello from WebAssembly!"}"#)
        .build())
}
// JavaScript/TypeScript
import { HandleRequest, HttpRequest, HttpResponse } from "@fermyon/spin-sdk"

export const handleRequest: HandleRequest = async function(request: HttpRequest): Promise<HttpResponse> {
  return {
    status: 200,
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ message: "Hello from Wasm!" })
  }
}
# Python
from spin_sdk.http import IncomingHandler, Request, Response

class IncomingHandler(IncomingHandler):
    def handle_request(self, request: Request) -> Response:
        return Response(200, {"content-type": "application/json"}, b'{"message": "Hello!"}')

Why WebAssembly Microservices

1. Cold start: <1ms — vs Docker 500ms-5s, Lambda 100ms-1s

2. Memory: 1-10MB — vs Docker container 50-500MB

3. Security — Wasm modules are sandboxed. No file system access, no network access unless explicitly granted.

4. Language agnostic — Rust, Go, Python, JS, C#, C++ → all compile to the same Wasm format

5. Portable — Wasm runs anywhere. No “works on my machine.”

Key-Value Store

use spin_sdk::key_value::Store;

let store = Store::open_default()?;
store.set("user:123", b"Alice")?;
let name = store.get("user:123")?;

Deploy to Fermyon Cloud

spin cloud deploy
# Deployed in seconds. Global edge. Free tier.

Building microservices? Check out my developer tools or email [email protected].

One Good Post Should Turn Into More Than One Post

Part 8 of 9

I really dislike the “write one post, publish it once, move on” pattern.

If you already did the hard work of writing something useful, the tool should help you squeeze more leverage out of it.

That batch became:

  • repurpose_post
  • content_gap_analysis
  • series_planner

The goal here is simple: more mileage, better follow-up angles, and less starting from zero every time.

Repo:
https://github.com/abanoub-ashraf/substack-mcp-plus