AlgoLens
AI pre-post reviewer that scores and rewrites posts for X
Live at algolens.app

AlgoLens is an AI pre-post reviewer for X: paste a draft and get a content score with a per-signal breakdown (likes, replies, reposts, dwell, negative signals) and content qualities like relatability and curiosity, plus specific suggestions and AI rewrites in three styles. It also analyses any X post and repurposes long-form into X. Free in open beta, live at algolens.app.
At a glance
- Role
- Solo: design, build, deploy
- Scope
- Content scoring with a per-signal breakdown, specific suggestions, AI rewrites in three styles, post analysis, repurpose-to-X, and a Chrome extension (pre-store)
- Constraints
- Scores and suggestions had to be specific and consistent enough to trust on a real draft, not generic AI feedback a writer would ignore
Stack
- React
- Vite
- JavaScript
- Tailwind CSS
- Express
- SQLite
- Anthropic API
- OpenRouter
- Gemini 2.5 Flash
Deep dive
You paste a draft, and AlgoLens tells you whether it's clear and worth reading: a score, the parts that are weak, and a few rewrites to steal from. That's the whole job, and it shapes everything about how it's built.
What it is
AlgoLens is a small SaaS web app for people who post on X and want a second opinion before they hit send. You paste a draft, and it returns a content score out of 100, a breakdown of engagement signals, and a few rewrites in different formats. There's also a Chrome extension that does the same thing inside the X compose box (built, not on the Web Store yet), and a couple of adjacent tools: one that reverse-engineers the "writing DNA" of a post you admire, one that repurposes a link or a blob of text into native posts.
It's for solo posters and small creators. Not agencies, not teams. One person with a draft and a nagging feeling it could be better.
The constraints
The content score is a designed heuristic, not a machine-learning model, and I don't present it as anything more.
The other constraint is that I'm one person running this on a hobby budget, so the backend stays deliberately small, only what a beta actually needs and nothing more. That decision shows up in small places all over the app.
Architecture on a napkin
React (Vite) ──httpOnly JWT cookie──> Express
│
authenticate ─> checkLimits ─> reserve quota
│
Anthropic (Claude) ─fails─> OpenRouter (gemini-2.5-flash)
│
shape check on the JSON
│
SQLite (better-sqlite3)
Nothing exotic. The frontend never holds a token in JavaScript; auth rides in an httpOnly cookie, so a request is a plain fetch with credentials: 'include' and no Authorization header. The server authenticates, checks limits, calls the model, writes usage and history to SQLite, and returns JSON. The Chrome extension is another client hanging off the same API with its own token type.
The stack, and why
Express 5 and better-sqlite3 on the backend, because a synchronous SQLite driver is the least infrastructure that can hold a beta. React 19, Vite 7, and Tailwind on the frontend, because that's the fastest edit-refresh loop I know. For the model, the Anthropic API is the primary path and OpenRouter is the fallback, and both models are set by env var, so swapping one is a config change instead of a rewrite. That flexibility turned out to matter more than I expected.
It started with OpenRouter alone. I'd built against anthropic/claude-3.5-sonnet there, and one day the slug started returning 404s: OpenRouter had retired it. I moved the default to google/gemini-2.5-flash, which returned schema-valid JSON and, by my estimate at the time, cost about seven times less per call. For a tool that makes an LLM call on every single analyze, that cost gap is the difference between a viable free beta and a bill I can't pay. Later I put a Claude model on the Anthropic API in front, and kept gemini on OpenRouter as the fallback, so one provider having a bad day doesn't take the app down.
How an analysis actually happens
A user pastes a draft and hits analyze. The request lands in routes/analyze.js, and the first things that touch it aren't the AI, they're the guards.
The text gets sanitized, then length-checked. Then, for a signed-in user, the endpoint reserves a quota slot before spending a cent on a model call (excerpt):
const sanitizedText = sanitizeInput(text);
if (rejectIfTooLong(req, res, sanitizedText)) return;
let usageId = null;
if (req.user) {
const reservation = reserveFeatureUsage(req, 'analyze');
if (!reservation.allowed) {
return res.status(429).json({ error: reservation.error, reason: reservation.reason, message: reservation.message });
}
usageId = reservation.usageId;
}
That reserve-first order is deliberate. An earlier version counted usage after the AI call, which meant two requests firing at once could both check the limit, both pass, and both spend. Reserving a slot before the call, and refunding it if the call throws, closes that race for signed-in users. Guests go through separate IP-level limits instead. It's the same pattern I use in the optimize route, and getting analyze to match it was a specific fix in the history.
If nothing's cached, the endpoint builds the prompt and wraps the user's text in XML tags before handing it to the model:
const userContent = `<user_post>${sanitizedText}</user_post>\n\nMedia flags: hasImage=${hasImage}, hasVideo=${hasVideo}, hasLink=${hasLink}`;
The tags are one prompt-injection mitigation. sanitizeInput strips user-supplied tags and neutralizes anything that could re-form the delimiter, the three media flags are coerced to strict booleans before they reach the prompt, and the system prompt tells the model to treat <user_post> as data.
Then the model call goes out, Anthropic first and OpenRouter if that fails. The reply is parsed as JSON and checked against a small per-route shape: the fields the dashboard and extension depend on must be there, with the right types. A reply that fails the check counts as a failed call: it triggers the fallback, or an error and a refunded quota slot if the fallback fails too, instead of being cached or shown. The check covers shape, not value ranges; if I wanted more, range checks on the score would be next. The result gets cached with the user's id baked into the key, history gets recorded, and the parsed JSON goes back to the browser.
What I'd do differently
I built too much for a beta with no users yet.
By launch, AlgoLens had five distinct tools: analyze, decode, optimize, replicate, repurpose. Plus a Chrome extension with its own token system, its own structured error contract, its own dev-versus-prod environment flipping, and end-to-end tests. All of that is real, working code I'm not embarrassed by. But none of it was validated against a single paying user, because there weren't any yet. I was polishing the fifth feature and the browser extension before I knew whether anyone wanted the first one.
The honest version is that building felt like progress and talking to users felt like risk, so I kept building. If I did it again I'd ship analyze alone, get it in front of ten real posters, and let their reaction decide whether feature two ever gets written. The scope I shipped is a bet that I already knew the answer. I didn't.
Numbers
As of October 2026, the backend runs 28 Jest suites and 244 tests against an in-memory database with the AI mocked.
I don't have user numbers to share yet, which is the whole point of the section above.
Where it is now
It's live in open beta, with every feature free. The next job is the one I skipped: getting it in front of real posters and letting them decide what comes next.
Status
Open beta · live on algolens.app
Have a project like this?
Tell me what you’re building, or book a short call about a similar system.