Pixel

Pixel Updates & Events

Server Updates & Events

Announcements

Intl.DurationFormat: Fix Duration Formatting for French

DEV Community

My Duration Formatter Broke in French. Intl.DurationFormat Doesn't.

Here's a function that ships in more codebases than anyone wants to admit: function formatDuration(hours, minutes, seconds) {
const parts = [];
if (hours > 0) parts.push(${hours} hour${hours !== 1 ? "s" : ""});
if (minutes > 0) parts.push(${minutes} minute${minutes !== 1 ? "s" : ""});
if (seconds > 0) parts.push(${seconds} second${seconds !== 1 ? "s" : ""});
return parts.length ? parts.join(", ") : "0 seconds";
}

formatDuration(1, 46, 40); // "1 hour, 46 minutes, 40 seconds"
It works. It ships. Code review doesn't blink — the ternary is a little ugly, but it's correct, and everyone's seen worse. Then the product adds a French locale, and formatDuration keeps returning "1 hour, 46 minutes, 40 seconds" to someone whose UI is in French everywhere else. Nobody wrote a bug here. formatDuration does exactly what it was written to do. The bug is that it was only ever written to do one thing: speak English. The ternary that only counts to two The !== 1 ? "s" : "" trick works in English because English plurals are binary — one thing, or not-one things. Pick any other language and that assumption quietly stops holding: French still needs the trailing "s" logic, but also reorders the sentence with "et" before the last item: 1 h, 46 min et 40 s. Russian and Polish have three plural categories, chosen by the last digit of the number, with exceptions for 11–14. Arabic has up to six — zero, one, two, few, many, other — each a genuinely different word shape, not just a suffix. A two-way ternary can express exactly one rule: English's. It's not almost right for everything else; it's correct for one language and silently wrong for the rest, in a way that never throws, never logs, and never shows up until someone files "the app is broken in French" with no repro steps that mean anything to you. This is also why the npm ecosystem has half a dozen duration-formatting packages, most of them shipping a locale-data JSON file to work around exactly this. The browser has had equivalent locale data loaded since the day it started, for Intl.NumberFormat and Intl.RelativeTimeFormat. Duration formatting was the gap. As of 2025, it isn't anymore. Intl.DurationFormat, tested for real 🎮 Try it yourself ▶️ Open the interactive playground → Runs right in your browser — poke at it and watch the concept react live. Intl.DurationFormat takes a locale and a style, and formats a duration object built from plural field names — hours, minutes, seconds, up through years: const dtf = new Intl.DurationFormat("en", { style: "long" });
dtf.format({ hours: 1, minutes: 46, seconds: 40 });
// "1 hour, 46 minutes, 40 seconds"

new Intl.DurationFormat("fr-FR", { style: "short" })
.format({ hours: 1, minutes: 46, seconds: 40 });
// "1 h, 46 min et 40 s"
Same duration, same call shape, correct output in both languages — word order, conjunction, and all. Nothing about your code changes between locales; only the string you pass as the first argument does. Four style values cover the usual cases, and short is the default if you omit it: const d = { hours: 1, minutes: 46, seconds: 40 };
new Intl.DurationFormat("en", { style: "long" }).format(d); // "1 hour, 46 minutes, 40 seconds"
new Intl.DurationFormat("en", { style: "short" }).format(d); // "1 hr, 46 min, 40 sec"
new Intl.DurationFormat("en", { style: "narrow" }).format(d); // "1h 46m 40s"
new Intl.DurationFormat("en", { style: "digital" }).format(d); // "1:46:40"
I ran all four of those in a real browser before putting them in this post, rather than trust a blog post that did the same thing eight months before this API's behavior settled — and they're exactly what's above, character for character. Three sharp edges, found by actually calling it The surface API is simple. A few of its rules only show up when you poke at it, and they're worth knowing before they poke back. Field names are strict, and wrong ones don't warn you. The object is keyed by plural unit names only — hours, not hour or h. Pass t...

Powered by Readybot.io

Intl.DurationFormat: Fix Duration Formatting for French

Forced Colors Test Reveals SVG Accessibility Gaps

DEV Community

I turned forced colours on for all 76 of our pages, and 39,617 drawing colours ignored it completely

Forced colours mode is the setting behind Windows High Contrast. When it is on, the browser throws away most of your colour decisions and repaints the page in a palette the user chose: one colour for text, one for background, one for links, one for buttons. It is not a dark theme. It is a user saying "I will decide what things look like, thank you". What makes it interesting to test is that the override is partial. The browser forces some properties and leaves others alone, and the ones it leaves alone are exactly the ones a drawn interface relies on. Nakodo is a drawn interface. Its public pages contain no photographs and no raster images, just line art. So I turned forced colours on for all 76 pages and counted what changed. Running it Chrome can emulate the mode, which means you can test it without a Windows machine: await page.cdp("Emulation.setEmulatedMedia",
{ features: [{ name: "forced-colors", value: "active" }] });
By hand it is DevTools, the Rendering panel, "Emulate CSS media feature forced-colors", set to active. Readers can follow along on any page of the site. Then on each page I walked every element and recorded its computed color, backgroundColor, fill and stroke, and whether each had become a system colour or kept its own. The counts Across 76 pages: Measure Count Elements 59,700 of which SVG elements 34,175, or 57 per cent color values replaced by the system palette 57,607 color values that kept their own value 2,093 SVG strokes untouched 23,733 SVG fills untouched 15,884 Declarations of forced-color-adjust on the site 0 The first line of the result is good news. Text is the thing forced colours is for, and 57,607 of 59,700 elements hand their text colour straight over. Paragraphs go white, links go yellow, buttons become outlined boxes. The pricing page in this mode is entirely readable: three plan cards as black boxes with white borders, every feature line legible, every price legible. The one thing it loses there is emphasis. In normal colours the recommended plan is an inverted dark card that pulls your eye across the row. In forced colours all three cards are identical, and the only reason you can still tell which one is recommended is that the word "RECOMMENDED" is text rather than a colour. That is the rule this mode teaches, and it is worth writing on a wall: any distinction carried by colour alone does not survive, and any distinction also carried by a word or a shape does. The 57 per cent the mode does not touch More than half of every page here is SVG, and forced colours mode does not override fill or stroke. That is per spec, not a Chrome bug: the forced properties are things like color, background-color, border-color and outline-color. Paint on a shape is left to the author. So 39,617 colour values across the site carry on exactly as they were, on top of a background the user has just changed to black. Whether that is fine or catastrophic depends entirely on which colour each shape was drawn in. Measured against the forced background: Drawing colour Contrast against the forced canvas paper 0 (the fill inside drawings) 20.63:1 ochre 400 (the spot colour) 11.46:1 cyano 800 (the brand mark) 2.23:1 ink 900 (every outline) 1.33:1 The top two are better in forced colours than they are in daylight. The bottom two are the problem, and the bottom one is the whole line system. Every outline in every drawing on the site is --illo-line, which is ink 900, which lands at 1.33:1 against black. A line at 1.33:1 is not a faint line. It is not there. What saves the screenshots from being empty is that the drawings are not outlines alone. They are shapes filled with paper and spotted with ochre, with ink outlines on top. The birds on the home page are still perfectly clear in forced colours: white bodies, ochre wings, and outlines you cannot see but do not need. The autumn leaves in the hero, which are mostly ink with a little ochre, are mostly gone. The clearest single failure is the brand mark in the heade...

Powered by Readybot.io

Forced Colors Test Reveals SVG Accessibility Gaps

Verify AI Agents with zkVerify Receipts

DEV Community

Trust the Agent. Verify It My Agent Games Receipts

Don't Trust the Agent. Verify It — My Agent Games Receipts My agent makes claims all day. "I checked the balance." "The recipient matches." "The transfer is safe." A claim is not evidence. Crypto already solved this for money: don't trust, verify. zkVerify does the same for agents. Agent Games Season 01 (21 Oct – 21 Nov 2026) asks Creator Track entrants to show their work. Here is mine — 9 things I actually did, with receipts. 01 The problem → an agent can misread a number, mix up two addresses, or approve what it should reject. It can be buggy or compromised. A proof is valid or it isn't. There is no "mostly correct." 02 Registration → I registered @rbby24 on agentgames.zkverify.io without a browser or wallet extension. Asked for a sign-in nonce, built the message, signed it with a fresh wallet, sent it back. Session valid to 15 Dec 2026. User ID 32a8225a-4920-4402-8633-a5b62b3d00ae. 03 The wire format → the hard part wasn't the signature. The handle is claimed through a server action that sends its arguments as an array in a plain-text body — not form data. My first 5 tries sent the wrong shape. The server answered every one with the same useless error: just a code, no explanation. 04 The rate-limit trap → then "Too many registration attempts." It was a rate limit — and it was hiding the real problem. The format was still wrong. Two different failures, same blank response. I only figured it out by reading the site's own JavaScript. 05 The fix → once the format was right, it worked. @rbby24, 0 points, 0 verified proofs, not ranked. Ugly process. But the failure is the point: if agents are going to use the web, the web has to work for agents too. 06 The circuit → registration proves an agent exists, not that it decides correctly. So I used the tournament's own gatekeeper circuit — the zero-knowledge circuit that decides whether a transfer gets approved. Downloaded the official files, installed snarkjs 0.7.6, ran it locally. 07 The proof → clean case: balance 1460 vs minimum 1000, amount 500 vs max 1000, recipient matches, expiry block 8,430,000 ahead of 8,420,000, verification-key hash matches the registered one. Decision: approve. The proof came out 805 bytes, 13 public signals. Verification returned OK. Anyone can rerun it: snarkjs groth16 verify verification_key.json public.json proof.json
[INFO] snarkJS: OK!
08 The negative test → the real test is whether the circuit lets a wrong decision through. It doesn't. Approve a transfer to the wrong recipient? The proof can't even be built — witness generation fails. Reject a valid request? Same. A wrong decision doesn't produce a bad proof. It produces no proof at all. 09 What it doesn't prove → no live score, no endpoint built, 0 points, not ranked. The live track needs a public endpoint answering signed tasks in 60 seconds, and I didn't build one. What it proves is narrower: the verification layer works as advertised, an outsider can use it end to end, and it fails in the right direction — it refuses wrong decisions instead of rubber-stamping them. Don't trust the agent. Verify it. The tools are public, the circuits are downloadable, and the proof is 805 bytes. Try it yourself. Agent Games Season 01 runs to 21 November 2026, judging 22–30 November. #AgentGames @ZKVProtocol

Powered by Readybot.io

Verify AI Agents with zkVerify Receipts

Fix GitHub Workflow File Push Permission Errors

DEV Community

Refusing to allow a GitHub App to create or update workflow files

The message refusing to allow a GitHub App to create or update workflow files comes from GitHub's git server during a push, not from Actions, and it names a grant that is deliberately separate from repository write access. Which grant depends on what pushed: an App installation needs a repository permission, and an OAuth token needs a scope with a different name. Three identities, three names for the same privilege, and one of them cannot be granted from a workflow file at all. What this error means A push is rejected and nothing else about the branch is unusual. The same token pushed every other file in the same commit range without complaint, and the only difference in the failing change is that it touches something under .github/workflows. The rejection is printed by git, so it appears in a bot's log in the middle of a checkout or push step rather than in any Actions annotation, and the branch simply does not appear on GitHub. `Quoted from Fragment256/githatch#60, a push rejection reported verbatim ! [remote rejected] ci/fix -> ci/fix (refusing to allow a GitHub App to create or update workflow.github/workflows/ci.ymlwithoutworkflows permission)

Common causes

The App installation does not hold the Workflows permission

The commonest cause for bot pushes. An app configured for contents and pull requests can do everything else it needs and is refused on any push that touches .github/workflows, because the workflow file privilege is a separate repository permission.

A personal access token is missing the workflow scope

Classic tokens list workflow as its own checkbox, separate from repo. A token created for general automation usually does not have it, and the failure only appears the first time the automation edits a workflow rather than when the token was created.

The push is using the token the workflow run was given

That token cannot be granted this privilege at all. There is no key for it in the permissions block, so no amount of editing the workflow will help, and contents: write is not a substitute.

The grant was added but the installation was never re-approved

A new repository permission on an existing GitHub App installation has to be accepted before it takes effect. In our experience this is the version that produces the longest debugging session, because the app's settings page shows the permission the whole time.

How to fix it

Read the rejection and identify which grant it named

  1. Look for the words GitHub App or OAuth App in the message.
  2. A GitHub App means the workflows repository permission on the app.
  3. An OAuth App means the workflow scope on the token.
  4. Then confirm the grant on the credential in hand rather than on the settings page you edited.

Push with an App installation token that holds Workflows

Mint a short-lived installation token in the job and hand it to checkout, so the push is authenticated as the app rather than as the run. Keep the app's permission set minimal and add Workflows only to the installation that needs it.

```.github/workflows/sync.yml (illustrative)
- uses: actions/checkout@v7
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
Re-approve the installation after changing permissions Adding a permission to an app does not update installations that already exist. Accept the new permission for the installation on the organization or account, then mint a fresh token, because a token issued before the acceptance carries the old set. Split the change when only part of it touches a workflow If a bot generates both code and workflow files, let it push the code with the credential it already has and open a pull request for the workflow change, or keep the workflow file identical to one already on another branch. The scopes reference records that an identical file at the same path on another branch may be committed without the scope, which is a narrow exemption but occasionally the cheapest rou...

Powered by Readybot.io

Fix GitHub Workflow File Push Permission Errors

The New Senior Hire Has No Ramp-Up Time

DEV Community

You're the new senior hire, and there's no ramp for that

👋 I'm Parsa Jiravand — I work in IT, and this is Best Practice. One article every day, one soft-skills episode every week. It all lives at bestpractic.org — come join us. It's your second week, and someone's just asked what you think the team should do about the deploy pipeline that everyone agrees is held together with duct tape. You don't know whose duct tape. You don't know if the last three people who tried to fix it got overruled, or burned out, or promoted somewhere else for trying. You have an opinion forming anyway, because that's the job you were hired to do, and you can feel the gap between how much you actually know and how much weight your answer is about to carry. That gap is the whole first month now, and it didn't used to be this wide. The job changed shape before you noticed For a long time, "new senior hire" meant someone who'd clear a short, forgiving runway — a few weeks to read the code, sit in on standups, get a feel for who actually makes the calls versus who just talks in meetings — before anyone expected an opinion that mattered. That runway is the thing that's gone missing. Hiring has leaned harder toward senior roles this year, and junior postings have thinned out alongside it, which means a lot of teams aren't backfilling a ladder anymore — they're hiring one person to be the judgment, directly, because there's no one two rungs down to grow into it. If you got the senior title, you weren't hired to eventually know how the system breaks. You were hired already knowing it, and the first month is everyone — including you — finding out whether that's actually true here, on this particular pile of decisions someone else made before you arrived. That's a different test than the one the title implies. "Senior" sounds like it's testing your judgment. What it's actually testing, for the first several weeks, is your judgment about a system you have none of the history for. Those aren't the same skill, and the gap between them is exactly the thing nobody warns you about when they hand you the title. What's actually happening underneath Here's the part that's uncomfortable to say out loud: the people around you are reading your first few answers as a referendum on whether the hire was right. Not consciously, mostly. But the first opinion you give in a room gets weighted more than the fortieth will, because it's the only data point anyone has yet. And the thing most people do with that pressure is try to prove the title immediately — have a strong take in week two, fix the thing everyone's been complaining about, show you were worth it. It reads as confidence. It's actually the single easiest way to spend credibility you haven't earned on a decision you don't have the context to be right about. The quieter problem is that nobody tells you this is happening, so you don't know you're being read at all. You think you're just answering a question honestly. The room thinks you're declaring your operating style. What works The move that buys you the most room, and costs you nothing, is asking the people who've been there what's already broken that nobody's fixed. Not "what should I know," which gets you a tour. Specifically: "What's the thing people here already agree is a problem, that just hasn't gotten prioritized?" That question does three things at once. It gets you the map, for free, from someone who's already drawn it — which is faster than re-deriving it from the codebase. It signals that you're looking for consensus before you push, which reads as judgment rather than ignorance, even though you're asking because you genuinely don't know yet. And it tells you, by what people say and what they visibly don't say, where the actual landmines are — the thing everyone agrees is broken but is also someone's project, or someone's old decision, or tied to a person who's still in the room. The second move is narrower, and it's about the first opinion you're asked for in a room full of people who don't know you yet. ...

Powered by Readybot.io

The New Senior Hire Has No Ramp-Up Time

Why the Second AI Deployment Is the Real Product

DEV Community

The Second Deployment Is the Product

Originally published at Trida AI. The most expensive sentence in our line of work is "we've done this before." Said loosely, it means somebody on the team remembers a similar project. Said properly, it should mean something you can point to: a shelf of artifacts left over from the last deployment, which the next one starts from. The distance between those two meanings is where forward deployed engineering either becomes a business or stays a run of heroic one-offs. A first deployment is discovery at full price. The team learns what the client's data actually looks like, which approvals really matter, where the workflow breaks on a bad Tuesday. That learning is the most valuable thing the engagement produces, and the most perishable. If it lives in the heads of the engineers who did the work, it depreciates on a schedule nobody controls: people rotate, context fades, someone leaves. The second deployment starts close to zero, and the client pays again for lessons already bought once. Our position is simple. Judge a deployment team by what the first deployment leaves behind. The residue is the product. But residue does not curate itself, and most of what a project leaves behind is not reusable. Knowing which parts are, and doing the unglamorous work of making them so, is the discipline this post is about. What carries over Failure catalogues carry furthest. The ways an AI system fails in production repeat across clients because they come from the machinery, not from the business: a source replaced after indexing, quoted as if current; an ambiguous instruction resolved silently instead of asked about; a tool result trusted after the thing it described had changed; an approval requested after the action, not before. A catalogue entry names the shape, says how the system detects it, and says what happens instead. The detection and the response transfer almost intact. The examples inside the entry do not — they get redrawn from the new client's own data, because the old client's data is not ours to carry, and because a failure seen only in someone else's examples is one you will not recognise in yours. Eval sets transfer as skeletons. The question classes, the scoring rubric, the taxonomy of hard cases: those carry. The gold answers do not — what counts as correct is a business judgment, and it belongs to the business being judged. Starting from a skeleton still changes the second deployment's shape: a regression gate exists from the first week, in form, waiting for the new client's labels, instead of being invented under deadline pressure near the end, which is when eval sets usually get built, and why they so often get built badly. Connector patterns transfer; mappings do not. How a class of systems behaves — its authentication flow, its pagination, how it signals rate limits, whether its writes are idempotent — is knowledge that survives the move to the next client running the same class of system. What a field means inside one company's estate does not survive, and pretending otherwise is how a second deployment imports the first client's data model by accident. Review and tracing shapes transfer for a duller reason: the accountability questions are the same everywhere. What did the system do, on what evidence, who approved it, how do we undo it. The records that answer those questions can be rebuilt from a pattern, at a fraction of the cost of designing them from a blank page. What the first deployment hands over. Artifacts move through a curated library; judgments stay in the room. One catalogue entry, carried across. The check and the hold behaviour moved unchanged; the stamp and the staleness window were rewritten. Example content. What has to stay bespoke Data semantics stay. What "active customer" means, when a ticket counts as closed, which of three conflicting records wins: no library knows this, because it is not knowledge about systems. It is knowledge about one business, and it has to be learned in the room, every...

Powered by Readybot.io

Why the Second AI Deployment Is the Real Product

One Variable Font Replaces Five Font Files

DEV Community

I Was Shipping Five Font Files. One Variable Font Replaced Them All.

Open the network tab on a site with a half-decent type system and count the font requests. Four is normal. Six isn't rare. Every one of them is a separate .woff2 file for a separate weight — Regular, Medium, Semibold, Bold — sitting in the critical path before the browser can paint text in anything but a fallback font. Our design system had five: four weights plus a bold italic for pull-quotes. Nobody had decided that on purpose. It accumulated — a designer added Semibold for one component, Bold Italic followed for another, and eighteen months later the font folder looked like a weight had been added every time someone liked how it looked in Figma. The fix everyone reaches for first When this shows up in a performance audit, the fix is almost always the same: cut weights. Drop from five down to three. Merge Semibold into Medium. Ship Bold Italic as a font-style: oblique of regular Bold instead of a dedicated file. @font-face {
font-family: "Inter";
src: url("/fonts/inter-regular.woff2") format("woff2");
font-weight: 400;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-medium.woff2") format("woff2");
font-weight: 500;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-bold.woff2") format("woff2");
font-weight: 700;
}
This genuinely helps — three requests beat five. It also doesn't touch the actual shape of the problem: every weight you keep is still its own file, its own request, its own round trip. Cut too aggressively and you lose the weight your design actually needs; cut too little and you're back where you started with a slightly smaller number. Before you keep reading — guess how many of those weights the browser actually downloads on a page that only uses Regular body text and one Bold heading. The honest (and slightly surprising) answer is: two. Browsers don't fetch an @font-face file the moment it's declared — the CSS Fonts spec requires user agents to download only the faces a page's rendered text actually calls for, so Medium, Semibold, and Bold Italic never leave the server for this particular page. That's the one thing "cut weights" already gets right, mostly by accident. What it doesn't fix: those two downloads are still two separate round trips, and the moment any other page in the same design system reaches for a third weight — a Semibold stat callout, a Bold Italic pull-quote — that page pays for a third file, one request at a time, because nothing about a static setup can serve more than the single weight baked into each one. It's not about how many weights — it's about how many files Here's what the "cut weights" fix never addresses: a static font file is a fixed snapshot. One file draws letterforms at exactly 700 and nothing else. Want 650 for a hover state that should feel "almost bold but not quite"? You can't — 650 doesn't exist unless you shipped a sixth file for it. The weight scale looks continuous in your design tool and is actually a handful of hardcoded stops in production. A variable font isn't a set of snapshots. It's one file that encodes the whole range as interpolation data — the letterforms at 100 and the letterforms at 900, with the browser computing everything in between on the fly. Ask for font-weight: 538 and you get a real, rendered 538, not a browser fallback or a rounded guess. @font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2-variations");
font-weight: 100 900; / a range, not a single value /
font-stretch: 75% 125%;
}
That's the whole type system. One @font-face block. One network request. Every weight from thin to black, plus every stop in between, available from the same file: h1 { font-weight: 700; }
h2 { font-weight: 600; }
body { font-weight: 400; }
.btn:hover { font-weight: 538; } / yes, this is a valid value now /
Five requests become one. The five-weight design system from the top of this post could have shipped as a single file from day one — nobody needed to decide anything ab...

Powered by Readybot.io

One Variable Font Replaces Five Font Files

Handle API Rate Limits in Python with Backoff

DEV Community

How to Handle API Rate Limits in Python Like a Pro (with Exponential Backoff)

If you are building applications that rely on external APIs (like OpenAI, Gemini, GitHub, or even just fetching job data), you have probably run into the dreaded 429 Too Many Requests error. When I was building my AI backend, my server kept crashing because I was hammering the Gemini API too fast. If you just write a try/except block and instantly try again, you will get banned. You need Exponential Backoff. Here is how it works and how to build it in Python. What is Exponential Backoff? Instead of retrying a failed request immediately, you wait a little bit. If it fails again, you wait twice as long. If it fails again, you wait twice as long as that. · Attempt 1 fails -> Wait 1 second. · Attempt 2 fails -> Wait 2 seconds. · Attempt 3 fails -> Wait 4 seconds. · Attempt 4 fails -> Wait 8 seconds. This gives the API server time to breathe and clear its queue, making your request much more likely to succeed on the next try. The Python Code (Using the tenacity Library) You could write this logic from scratch using a while loop, but why do that when Python has an incredible library called tenacity? First, install it: pip install tenacity
Now, let's build a robust API request function: import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

We only want to retry if we hit a 429 (Rate Limit) or 500 (Server Error)

def is_rate_limit_error(exception):
return isinstance(exception, httpx.HTTPStatusError) and exception.response.status_code in [429, 500]

@retry(
retry=retry_if_exception_type(httpx.HTTPStatusError),
wait=wait_exponential(multiplier=1, min=2, max=10),
stop=stop_after_attempt(5)
)
async def fetch_external_data(url: str):
print(f"Attempting to fetch: {url}")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)

    # Raise an exception if the status code is 4xx or 5xx
    response.raise_for_status() 
    return response.json()

What does this code do? · wait_exponential(multiplier=1, min=2, max=10): This is the magic. It will wait 2s, then 4s, then 8s, capping at 10s so you don't wait forever. · stop_after_attempt(5): It will try 5 times before finally giving up and raising the error to your main application. Bonus Tip: Respect the Retry-After Header If an API is really nice, it will send a Retry-After header in the 429 response telling you exactly how many seconds to wait. Tenacity can be configured to read this, but even with a standard exponential backoff, your server will stop crashing and start handling failures gracefully. Have you ever had your server crash from an API rate limit? Let me know below!

Powered by Readybot.io

Handle API Rate Limits in Python with Backoff

Supabase Security Check for AI-Built Apps

DEV Community

24,664 AI-built apps sent database records to any visitor. I built a one-command check that proves yours does not.

24,664 AI-built apps sent database records to any visitor. I built a one-command check that proves yours does not. In November 2026, German security firm Crissy Field (Hamburg), working with the newspaper Handelsblatt, published research on AI-built apps. The numbers are the reason I built this tool, so here they are first, with the caveats attached. The researchers scanned 159,041 Lovable projects found in Common Crawl (apps created December 2025 to September 2026). Of those, 34,545 used Supabase as their backend. 24,664 websites sent database records to the browser as soon as the page loaded, without any login or interaction. With further read-only requests using the same public key the app itself ships, the researchers found records containing email addresses in 10,560 tables across 7,403 sites (30%). Worst cases, each a separate app: one service provider exposed more than 1,000 identity records (passport numbers, expiry dates, nationalities); one lifestyle forum exposed more than 200,000 private chat messages and 80,000 profiles; one app exposed about 280,000 customer emails. Read those numbers honestly, because the researchers are explicit about the limits: 30% is NOT a confirmed leak rate for all Lovable apps. The sample is not representative, some email addresses are public contact or test data, and records without an email address were not counted. The researchers could not establish whether any exposed data was misused. Record counts are a floor, not a total: only part of each dataset was retrieved. This was not a hack of Lovable's systems. The researchers used read-only requests with the same public key the app itself ships. The root cause is Row Level Security left off in the app's Supabase database, with the public anon key doing the rest. The same class of bug had already been disclosed eight months earlier as CVE-2025-48757 (March 2025, CVSS 9.3): researchers scanned 1,645 Lovable showcase sites and found 170 with tables open to anonymous reads. Lovable added a Security Scanner and an AI Security Reviewer after that, and the exposure repeated at a larger scale in November. The lesson is not "run a scanner once". It is "prove, on every deploy, that an anonymous request returns zero rows". I built a small tool that does exactly that. It is called vibeleak, and it is a zero-dependency TypeScript CLI. MIT licensed. It starts from only the deployed app URL, finds the Supabase project URL and anon key in the JavaScript the app already ships, then probes each table and storage bucket as an anonymous visitor. One command, one verdict: CLEAN, LEAK, or REVIEW, with a JSON report and a baseline diff so a closed table that opens again fails CI. The problem: the key is already public, and nobody checked the lock When an AI tool builds an app on Supabase, it ships a public anon key in the frontend JavaScript. That is by design: the browser needs the key to talk to the database. The only thing standing between a stranger and your tables is Row Level Security. If RLS is off, or a policy says USING (true), the anon key reads the whole table, and the app helpfully sends the rows to any visitor on page load. Nobody checks this because the app works. The login page looks right, the dashboard loads, the data is there for the right user. The failure is invisible from inside the app. You only see it when you make the same request the app makes, without logging in, and count the rows that come back. That is what vibeleak does. It fetches the app the way any visitor does, extracts the Supabase URL and anon key from the shipped JavaScript (the same code every browser downloads), enumerates the tables through the PostgREST OpenAPI document, and probes each one with an anonymous GET limited to one row. It stores column names and a 0/1 rows-visible flag only. It never stores row values. It never writes anything: GET and HEAD only, no POST, no RPC calls, no writes of any kind. What it does Five commands: vibeleak scan [--i-own-t...

Powered by Readybot.io

Supabase Security Check for AI-Built Apps

Why SongUp AI Offers a 30-Second Free Song Preview

DEV Community

Pay after you hear it: why our AI song maker sings 30 seconds free first

I build SongUp AI, so this is a maker's build note, not a neutral review. When you sell something people have never bought before, the hardest question is not "how much?" It is "what am I actually getting?" A custom song is a strange purchase. Nobody can picture it in advance. You type a few lines about your partner, your dad or your dog, and you are asked to pay before you know if the voice, the style or even the words will work. So on SongUp AI anyone can hear the first 30 seconds of a song made from their own lyrics before paying anything. Here is the thinking behind that design. 1. The preview has to be their song, not a demo A sample in the chosen style only tells you what "acoustic pop" sounds like. What people want to know is how their chorus sounds with their person's name in it. So the free preview is generated from the user's own words. It costs more to run than playing a sample, but it is the only preview that answers the real question. 2. Thirty seconds is a design decision, not an accident Too short and the song never reaches the chorus. Too long and there is no reason to keep the full song. Thirty seconds usually covers an intro and the first verse or hook, which is where the name and the first memory land. That moment, hearing a name sung, is what people react to. The full song after that is one payment of $2.99, and plans start at $9.99 a month for people who make a lot of songs. Both are on one page so nobody has to dig for the price: songupai.com/pricing. 3. Show the price before the button, not after "Unlock full song" buttons that only reveal the price on the checkout page are common, and every surprise there costs trust. A price printed next to the button may get fewer clicks, but the clicks that happen come from people who already decided. 4. Be honest about what AI makes Some requests get refused by the music model (certain names, long descriptions), and some lines come out sung oddly. We would rather a user hear that in a free preview than after paying. It also means support tickets start with "can you fix this line?" instead of "I want a refund." The short version If your product's value is a feeling, let people have the feeling before they pay. In our case that is 30 seconds of a song built from their own words. Here is a 16-second clip of the idea (the presenter is AI-generated, not a customer): What do you preview for free in your product, and how did you choose where the free part ends? Pricing details: songupai.com/pricing

Powered by Readybot.io

Why SongUp AI Offers a 30-Second Free Song Preview

Latchkey vs WarpBuild: GitHub Actions Runner Costs

DEV Community

Latchkey vs WarpBuild for GitHub Actions runners

On managed runners Latchkey is $0.0025 a minute at 2 vCPU against WarpBuild at $0.004, but that is not the comparison most WarpBuild users are making: its bring-your-own-cloud mode runs in your own AWS, GCP or Azure account at $0.002 a minute, which is cheaper than both. The question is whether you want to own the cloud account the jobs run in. One month of 10,000 Linux minutes at 2 vCPU on a private repository at each published rate. The BYOC bar is the WarpBuild orchestration charge only; the instances are billed by your own cloud provider on top. Rates read 20 September 2026. WarpBuild sells managed GitHub Actions runners at $0.004 a minute for 2 vCPU and 8 GB, adopted by installing its bot from the WarpBuild dashboard rather than the GitHub marketplace and setting runs-on to a runner ID such as warp-ubuntu-latest-x64-4x. It also sells a bring-your-own-cloud mode at $0.002 a minute where the machines run in your own cloud account, publishes arm64 at $0.003, and ships Windows and macOS runners. Its cache is metered separately at $0.20 per GB-month plus a charge per write or restore. Latchkey sells managed runners only, at $0.0025 a minute for 2 vCPU and 8 GB, in its own AWS estate, with a self-healing agent that repairs transient failures inside the run. There is no BYOC mode, no arm64, no Windows and no macOS, and the cache is not metered separately. Every cell below was read from the vendor's own public pricing or documentation page on 20 September 2026. Where a vendor does not publish a number, the cell says "not published" instead of carrying a guess. Comparison WarpBuild Latchkey Price per minute, 2 vCPU Linux x64 $0.004 (2 vCPU, 8 GB RAM) $0.0025 (2 vCPU, 8 GB RAM, 100 GB disk) Price per minute, 4 vCPU Linux x64 $0.008 (4 vCPU, 16 GB RAM) $0.005 (4 vCPU, 16 GB RAM, 100 GB disk) Bring your own cloud $0.002/min running in your own AWS, GCP or Azure account Not offered Free tier Not published. Sign-up needs no card until you reach a usage or credit limit 30-day trial, then 2,000 included minutes a month on Developer, 4,000 on Launch, 6,000 on Scale Billing granularity Per minute, in its own words: "CI runners are billed on a per-minute basis" Per minute Runner sizes and OS 2 to 32 vCPU on Linux x64 and arm64; Windows from 4 to 32 vCPU; macOS M4 Pro at 6 and 12 vCPU; Linux on Apple silicon at 6 and 12 vCPU 2, 4, 8 and 16 vCPU, Ubuntu 24.04 on x86_64 only. arm64, Windows and macOS are not offered Cache: type, limit, persistence Metered: $0.20 per GB-month of storage plus $0.0001 per write or restore; snapshot restore $0.04 a job and $0.025 an hour of snapshot storage Size not published. latchkey-dev/cache-action@v1 replaces actions/cache with a restore step and a save step, keeping path and key (no restore-keys); entries are isolated per organization, versioned by OS, kept 14 days; a failed restore warns rather than failing the job Docker build acceleration Remote BuildKit builders on their own meter, 16 vCPU at $0.06/min to 192 vCPU at $0.88, driven by Warpbuilds/build-push-action@v7; the shared cache is eventually consistent with a 10-day TTL latchkey-dev/docker-cache-action@v1, layers held in a Latchkey-managed private registry per organization Concurrency limits Advertised as unlimited job concurrency; the no-queue guarantee and an SLA behind it are Enterprise features Developer 20, Launch 40, Scale 80 busy runners per workspace; idle warm runners do not count Where jobs run WarpBuild's own cloud, or your AWS, GCP or Azure account under BYOC Latchkey's own AWS estate on m6a-class hosts, one job per runner, no inbound access, destroyed after the job Self-healing or retries Not offered Built in: transient failures are diagnosed and retried inside the run Setup change required Sign up, install the WarpBuild bot from its dashboard rather than the GitHub marketplace, then set runs-on to a runner ID such as warp-ubuntu-latest-x64-4x A runs-on label change. GitHub organization accounts only The BYOC rate is the real compar...

Powered by Readybot.io

Latchkey vs WarpBuild: GitHub Actions Runner Costs

GitHub Actions Matrix Exclude: Partial Match Explained

DEV Community

GitHub Actions matrix exclude not working, and the partial match

GitHub Actions matrix exclude not working is most often the opposite complaint from the one people arrive with: the entry matched more combinations than intended, not fewer. An exclude entry is a partial match, so naming one key removes every combination that agrees on that key, whatever the other keys hold. An exclude entry naming one key of two constrains only that key. Both combinations that agree on it are removed. What this error means The job count is wrong and nothing is annotated. Either several legs you wanted are gone, which is the partial match doing exactly what it is specified to do, or a leg you tried to remove is still there, which usually means it was not in the cross product to begin with. The tell in both directions is that the run succeeds and the job list is simply a different length than the arithmetic in your head. There is no message to search for, because nothing went wrong from the builder point of view. ```Illustrative matrix fragment, not a log line: this outcome produces no message matrix: os: [ubuntu-latest, windows-latest] node: [18, 20] exclude: - node: 18 two jobs remain, not three

Common causes

The exclude entry names fewer keys than the combination has

The dominant cause. One key named means one comparison, and every combination agreeing on that key is removed regardless of the others, which is usually a whole slice rather than a single job.

The entry names a value that is not in the cross product

A filter that matches nothing removes nothing, and it is not an error. This happens when a matrix value changes and the exclude entry describing it is not updated in the same change.

The job being excluded was created by an include entry

Configurations that include adds are produced after the exclusion pass has finished, so they are never tested against exclude at all. The only way to stop one is to change the include entry.

The matrix was changed and the count was never rechecked

In our experience this is how a wrong count survives for months. Adding one value to a dimension multiplies the product and can move which combinations an existing partial entry removes.

How to fix it

Name every key needed to identify the combination

To remove exactly one job, the entry has to constrain every dimension. Anything less is a slice, and the number of keys you name is the number of comparisons the builder creates.

```.github/workflows/ci.yml
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
exclude:
- os: windows-latest
node: 18
Print the matrix context and count the legs Add a step that serializes the matrix context to the log. Set fail fast to false so a single failure does not hide the rest of the expansion. Run it and compare the number of legs against the number you expected. Leave the step in place for whoever edits the matrix next. Change the include entry rather than excluding what it adds A configuration that only exists because include created it cannot be removed by exclude, because it is produced after the exclusion pass. Edit or delete the include entry instead. Build the matrix from include entries when the set is small If you want five specific combinations, list five include entries and define no dimensions to cross. Nothing has to be subtracted, so none of the subtleties on this page applies. strategy:
matrix:
include:
- os: ubuntu-latest
node: 20
- os: windows-latest
node: 22
How to prevent it Treat the key count in an exclude entry as the width of the filter, and check it. Keep a step that prints the matrix context permanently in any workflow with a matrix. Recheck the job count whenever a dimension gains or loses a value. Prefer an explicit list of include entries when the wanted set is small. An exclude entry constrains only the keys it names The published schema says it in one line: an excluded configuration only has to be a partial match for it to be excluded. The builder implements ...

Powered by Readybot.io

GitHub Actions Matrix Exclude: Partial Match Explained

Why C/C++ volatile Is Not Atomic in Multithreading

DEV Community

The Most Dangerous Keyword in C/C++: Why volatile Is Not Atomic

The Most Dangerous Keyword in C/C++: Why volatile Is Not Atomic One of the most persistent myths in systems programming is the belief that declaring a shared variable as volatile makes it safe for concurrent multi-threaded access. Consider the following multi-threaded counter: volatile int counter = 0;

void worker() {
for (int i = 0; i < 100000; i++) {
counter++; // DATA RACE & LOST UPDATES!
}
}
Running two threads executing worker() concurrently will almost never result in 200,000. Increments will be silently lost. To understand why, we must examine the difference between Compiler Optimizations and CPU Hardware Coherence. 1. What volatile Actually Does The volatile keyword was introduced into the C standard in 1989 for a single specific purpose: interacting with Memory-Mapped I/O (MMIO) hardware registers and asynchronous signal handlers. It communicates one instruction to the compiler: "Do not cache this variable inside a CPU register. Do not optimize away repeated reads or writes. Every read must emit a fresh load from memory." // Without volatile:
while (!flag) {} // Compiler may optimize to: if (!flag) while(1){}

// With volatile:
while (!flag) {} // Compiler emits a CMP instruction reading RAM every loop iteration
2. What volatile Fails to Provide Failure 1: Zero Atomicity The operation counter++ is not a single atomic hardware instruction. It decomposes into a 3-step sequence in assembly: mov eax, dword ptr [counter] ; 1. Read from memory into register
add eax, 1 ; 2. Increment register value
mov dword ptr [counter], eax ; 3. Write back to memory
If Thread 1 reads counter = 5, and is preempted before Step 3, Thread 2 can read 5, increment to 6, and write back. When Thread 1 resumes, it writes 6, completely obliterating Thread 2's increment. Failure 2: Zero Memory Barriers Modern superscalar CPUs (x86, ARM64) execute instructions out of order to maximize execution pipeline throughput. volatile imposes constraints on the compiler, but it emits zero hardware memory barrier instructions (like MFENCE or DMB). The CPU is free to reorder reads and writes around volatile variables across cache lines. 3. The Modern Solution: std::atomic For thread-safe operations in C and C++, never use volatile. Use the standard atomic types: #include

std::atomic counter{0};

void worker() {
for (int i = 0; i < 100000; i++) {
counter.fetch_add(1, std::memory_order_relaxed); // Hardware Atomic!
}
}
On x86, this compiles to the hardware instruction lock inc [counter], asserting the CPU bus lock and guaranteeing atomic execution. Read the full systems engineering breakdown at Binary Dose.

Powered by Readybot.io

Why C/C++ volatile Is Not Atomic in Multithreading

Cline Crash Dumps Exposed Plaintext API Keys

DEV Community

Cline's crash dumps wrote plaintext API keys to disk every ~7 minutes (GitHub #15427)

A Cline user running the JetBrains plugin reported the standalone cline-core process crashing with an out-of-memory error roughly every 6-7 minutes. The crash diagnostic meant to help debug it was itself the bigger problem: each crash wrote a ~240MB heap snapshot to ~/.cline/heapsnapshots/, and that snapshot contained hundreds of near-duplicate copies of the session's full in-memory state — including the user's provider API key, in plaintext. What the source says Per the GitHub issue, filed 2026-10-29: cline-core (JetBrains plugin 2.0.9, core 4.1.26) streams a full serialized copy of its globalState — chat transcript and API key included — to the JetBrains webview on every state change, with no backpressure on the update queue. During a burst of changes, memory grew from an idle ~270MB to several gigabytes in about 100 seconds before the process hit a JavaScript heap OOM (exit code 134). Maintainer mkondratek confirmed on 2026-11-02 that the OOM shares its root cause with an earlier report, #15031: the queue never drops stale entries when the IDE falls behind. The same maintainer separately acknowledged the plaintext-key exposure the reporter flagged, saying it "is getting its own fix" tracked apart from the OOM bug itself. What it doesn't establish No data loss or external exposure is reported. The heap snapshots and the key stayed on the user's own disk, and the process restarted automatically after each crash — this isn't a breach, it's a credential sitting somewhere it shouldn't. As of 2026-11-09, only a telemetry commit had landed; neither the OOM fix nor the key-redaction fix was merged. Why it matters anyway Crash diagnostics are usually treated as inert — dump the state, ship it to the bug tracker, move on. This is a reminder that "dump the full in-memory state" and "that state contains a secret" are two decisions that have to be made together, and here they weren't. If you run Cline's JetBrains plugin, it's worth checking ~/.cline/heapsnapshots/ and rotating any key that was active during a crash. Full scoring breakdown and source: https://www.stupidllm.com/incident/STUPID-2026-0166/

Powered by Readybot.io

Cline Crash Dumps Exposed Plaintext API Keys

Automate Campaign Clipping With Claude Code

DEV Community

AI Clipping With Claude Code: How to Automate Clipping From Campaign Brief to Checked, Disclosed Clips

AI clipping usually means one thing: a tool finds moments in a long video and cuts them vertical. In a paid clipping campaign that is the easy part. Clips get rejected for the rest of the job: a missing hashtag, the watermark the brief asked for, the wrong length, an account that isn't verified, a disclosure that isn't there, a link submitted 40 minutes after posting. This post shows how to automate clipping as the whole job, with AI Clipping Skill, a free, MIT-licensed skill for Claude Code (it also runs in Codex, Cursor and OpenClaw). Your agent reads the campaign brief into a checklist, cuts one disclosed clip per account from the footage the campaign supplied, checks every clip against the brief in code, shows one plan page, and after one yes posts to TikTok, Reels and Shorts and hands you each link with the minute its submission window closes. Everything before posting runs on your own computer with Python and ffmpeg and works without signing up for anything. npx skills add social-media-skills/ai-clipping-skill -g -a claude-code -y
Repo: github.com/social-media-skills/ai-clipping-skill The example in the repo: a made-up campaign on a 9-minute NASA interview, three clips for three accounts. Where a real campaign's clip shows its paid-partnership line, the example shows "EXAMPLE ONLY: not a real ad". NASA and Bob Behnken endorse nothing here. What a clipping campaign asks for On Whop and Content Rewards, a campaign pays per 1,000 views for short clips of footage the brand or creator supplies. The brief sets the rules, and Content Rewards' own rejection guide says most rejections come from a detail the clipper skimmed past. A typical brief asks for: Money: a rate per platform, a minimum payout per clip (nothing below it), a cap per clip, a fee, a total budget that can run out. The caption: a hashtag, an @mention, a link, nothing from the banned list. The screen: a watermark or logo, a length range, 9:16. The footage: only the supplied files, sometimes only streams from the last few days. The account: verified in the marketplace, a link in the bio, sometimes a minimum share of viewers in the US and an engagement floor. After posting: submit the link within 30 minutes, one post into one campaign once, stay public and unchanged for 30 days, keep analytics screenshots as proof. And every campaign post is an ad. Content Rewards requires the disclosure at the start of the video and at the top of the caption, Whop's clipper terms require following the FTC's rules, and TikTok takes undisclosed branded posts out of For You. That list is why "automate clipping" can't stop at cutting. Each of those items is a check a script can run before the post exists. The prompt I joined a clipping campaign. Brief is in brief.txt, footage is behnken.mp4. Accounts: TikTok @example.clipper (everyone, comments on, duets and stitches off), Reels @example.clipper.reels, Shorts @exampleclipper (public, not for kids), all verified in the marketplace. No AI-made people or voices. That answers what the agent would otherwise ask in its first message: the brief, the footage, the accounts, whether each is verified, and the settings only the account owner may choose. When posting isn't connected yet, the agent puts the signup link at the top of that first message, so the account gets made while the clips render. Step 1: the brief becomes a checklist The agent copies the brief's own numbers and words into campaign.json, then: python3 /scripts/campaign.py campaign.json
1. Platforms and rates: TikTok $1.00 per 1,000 views, Instagram Reels $0.80 per 1,000 views, YouTube Shorts $0.50 per 1,000 views.
2. Payout: minimum payout per clip $5.00; cap per clip $150.00; marketplace fee 10%; total budget $3,000.00; $2,140.00 left when checked 16 Nov 13:30 UTC. When the budget runs out, views stop paying.
3. Pays from: TikTok 5,000, Instagram Reels 6,250, YouTube Shorts 10,000 views per clip; under that a clip earns nothing.
4. Every caption: sta...

Powered by Readybot.io

Automate Campaign Clipping With Claude Code

DOM Audit: 79 Pages, 23,767 Elements and Script Tags

DEV Community

Our 79 pages contain 3,083 divs and 3,053 script tags, and after hydration the scripts win

Lighthouse has an audit called "Avoid an excessive DOM size" that starts complaining at 1,500 elements, 32 levels of depth or 60 children on one parent. I have never once seen it fire on our site, so I never thought about the shape of the DOM at all. This week I measured it properly: every element on all 79 pages in the sitemap, the depth of every branch, every text node and every comment node. const all = [...document.querySelectorAll('')]; // elements
let max = 0;
(function walk(el, d) { if (d > max) max = d; for (const c of el.children) walk(c, d + 1); })(document.documentElement, 1);
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT);
Size elements, all 79 pages 23,767 of those, inside 19,230 elements per page 117 min, 259 median, 929 max body elements per page 65 min, 202 median, 862 max distinct element names site-wide 55 attributes 38,350 (1.61 per element) text nodes with content 9,001 characters in them 382,466 (42.5 per node) comment nodes 1,276 Against Lighthouse's thresholds: 0 pages over 1,500 elements, 0 over depth 32, and 2 pages with a parent holding more than 60 children. The heaviest page on the site, the homepage, is 62 per cent of the way to the warning. The lightest, the cookie policy, is 117 elements, which is small enough to read in the inspector without scrolling. The ratio I find most useful is at the bottom of that table: 5.03 elements per 100 characters of text. A content site is in the business of turning markup into reading, and that is the exchange rate. The tag histogram, and a tie Counted in the HTML we actually serve, all 79 pages: element count div 3,083 script 3,053 a 2,699 p 2,431 span 2,307 meta 1,932 li 1,254 path 1,004 link 866 svg 638 section 556 h2 552 nav 534 h3 439 dt / dd 233 each button 96 td 92 The ratio of script tags to divs is 0.99. On a static marketing site, for every structural div we serve, we serve a script tag. Exactly one of those 3,053 is ours, a single in the root layout. The rest are the framework: the chunk loaders, and above all the React Server Components flight payload, which arrives as a long series of inline push calls rather than as one blob. 200 of them are application/ld+json structured data, which is markup wearing a script costume. And in the live DOM, after hydration, script overtakes div: 3,134 against 3,083, because next/script injects one more element per page once the load event has passed. The most common element on this site is a script element, and nobody chose a single one of them. Check it yourself on any page: document.querySelectorAll('script').length // 43 on /faq
document.querySelectorAll('div').length // 36 on /faq
The FAQ is 268 elements and carries 43 script tags against 36 divs. Depth: 78 pages agree, one does not max depth pages 9 to 11 4 12 50 13 17 14 6 15 1 30 1 50 of 79 pages bottom out at exactly 12 levels, which is what a consistent layout component plus a content renderer produces. One page goes to 30, and here is its deepest branch: html > body > div > main > section > div > div > div > div > div > div > div
> div > div > div > div > div > div > div > div > div > div
> button > svg > g > g > g > g > g > ellipse
Seventeen divs, then a button, then five nested g elements, then an ellipse. That ellipse is part of the mascot on the homepage, drawn as inline SVG inside the demo panels. SVG groups account for 6 of the 30 levels and the illustration is not the problem; the 17 divs in front of it are. It is not wrapper soup, except where it is The metric I expected to be bad. A single-child wrapper is an element with exactly one element child and no text of its own, which is the unit of accidental div nesting: single-child wrappers 1,689 (7.1 per cent of all elements) of those, a div whose only child is a div 111 longest unbroken wrapper chain, 78 pages 2 longest unbroken wrapper chain, homepage 9 78 of 79 pages never stack more than two wrapper...

Powered by Readybot.io*

DOM Audit: 79 Pages, 23,767 Elements and Script Tags

Organize Downloads Automatically with Local AI

DEV Community

Cansei de arrumar minha pasta Downloads: montei um "zelador" com IA local que faz isso por mim

Cansei de arrumar minha pasta Downloads: montei um "zelador" com IA local que faz isso por mim Depois de passar a semana inteira lidando com Active Directory, Entra ID e tickets de infraestrutura, no fim de semana eu gosto de resolver um problema bem mais egoísta: a minha própria bagunça digital. Minha pasta de Downloads virou, com o tempo, um cemitério de PDFs com nome documento (3).pdf, prints de tela chamados Captura de tela 2026-...png, planilhas, instaladores e boletos — tudo misturado, sem nenhuma lógica. Já tinha brincado antes de montar um assistente de IA local para outras coisas, então resolvi reaproveitar a mesma ideia para um problema bem mais mundano: um script que observa a pasta Downloads, entende (de verdade, lendo o conteúdo, não só o nome) o que cada arquivo é, e move ele para o lugar certo — sozinho, rodando o tempo todo em segundo plano, sem nada saindo da minha máquina. A ideia por trás do projeto O conceito não é novo — encontrei pelo menos três projetos de código aberto que fazem exatamente isso, usando a mesma combinação de peças: uma biblioteca Python chamada watchdog para vigiar a pasta[1], e um modelo de IA rodando localmente via Ollama para decidir a categoria de cada arquivo[2][3]. A ideia central é simples e elegante: O watchdog detecta quando um arquivo novo aparece na pasta. O script extrai um pedaço do conteúdo — texto de PDFs, OCR de imagens, texto puro de .txt/.docx. Esse conteúdo (mais o nome do arquivo) vai para um modelo de linguagem rodando local, via Ollama, pedindo para ele responder com uma categoria entre as que eu defini. O script cria a subpasta se não existir e move o arquivo. O detalhe que me convenceu a fazer isso localmente, e não com alguma IA de nuvem: boletos, comprovantes, documentos pessoais e prints de conversas privadas passam por esse pipeline todos os dias. Com Ollama, o texto extraído nunca sai da própria máquina — o modelo responde via uma API local, sem nenhuma chamada externa[4][5]. Montando a base: Ollama O primeiro passo é instalar o Ollama e baixar um modelo pequeno, que já é suficiente para esse tipo de classificação simples (não precisa de um modelo gigante para decidir se um PDF é "nota fiscal" ou "contrato"): # instala o Ollama (Linux/WSL)
curl -fsSL https://ollama.com/install.sh | sh

baixa um modelo leve, bom o suficiente para classificação de texto

ollama pull llama3.2:3b
Isso sobe um serviço local expondo uma API HTTP em http://localhost:11434, que qualquer script Python pode chamar com a biblioteca requests[4][5]. O "zelador": monitorando a pasta com watchdog A segunda peça é o watchdog, uma biblioteca Python que observa eventos do sistema de arquivos — criação, modificação, remoção — e dispara callbacks[1]. A estrutura básica de um observador é direta: from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import time

class NovoArquivoHandler(FileSystemEventHandler):
def on_created(self, event):
if not event.is_directory:
classificar_e_mover(event.src_path)

if name == "main":
handler = NovoArquivoHandler()
observer = Observer()
observer.schedule(handler, "/caminho/para/Downloads", recursive=False)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
Esse handler dispara classificar_e_mover sempre que um arquivo novo aparece. É aqui que a IA entra. A função que "pensa": classificar com o modelo local A função classificar_e_mover faz três coisas: extrai um pedaço do conteúdo (texto puro, ou pdftotext para PDF, ou OCR via pytesseract para imagem), monta um prompt curto pedindo uma resposta em categoria fixa, e chama a API local do Ollama: import requests, json, shutil, os

CATEGORIAS = ["Notas Fiscais", "Comprovantes", "Trabalho", "Contratos", "Imagens", "Outros"]

def classificar(nome_arquivo, trecho_conteudo):
prompt = f"""Classifique o arquivo abaixo em UMA destas c...

Powered by Readybot.io

Organize Downloads Automatically with Local AI

Loan Against Property: Model the LAP Workflow in Python

DEV Community

What is a Loan Against Property (LAP)? How Do You Model Its Workflow in Code?

A developer's guide to the lending product that is mostly paperwork, and how to turn that paperwork into logic. If you build software for banks or NBFCs, sooner or later you will meet a loan against property, or LAP. On the surface it is a simple product. Underneath, it is one of the most document-heavy workflows in lending. This article explains how LAP works in plain terms, then models the key steps in Python: eligibility, document completeness, name matching across documents and exception routing. What is a loan against property? A loan against property is a secured loan. The borrower mortgages a property they already own, such as a house, flat or shop, and the lender gives them a loan against its value. The borrower keeps owning and usually keeps using the property. The lender holds it as security until the loan is repaid. That is the key difference from a home loan. A home loan funds the purchase or construction of a property. LAP raises money against a property the borrower already has, often for business expansion, working capital, education or medical expenses. How does a loan against property work? From a lender's point of view, every LAP application passes through two parallel tracks: Borrower assessment: Can this person repay? Income, existing loans, credit history and bank statements. Property assessment: Is this property acceptable security? Valuation, legal title, ownership history and existing claims. The loan is sanctioned only when both tracks pass. That is why LAP takes longer than a personal loan, and why it is such a good candidate for automation. How do you calculate LAP eligibility? Eligibility is the lower of two limits. The property limit. Lenders lend only a percentage of the property's value, called the loan-to-value (LTV) ratio. A property worth ₹1 crore at 60% LTV supports a loan of up to ₹60 lakh. The income limit. The EMI must fit within the borrower's income after existing obligations. Indian lenders often express this as FOIR (fixed obligations to income ratio): the share of monthly income that can go towards all EMIs combined. def emi(principal, annual_rate, months):
"""Standard reducing-balance EMI."""
r = annual_rate / 12 / 100
return principal * r * (1 + r) ** months / ((1 + r) ** months - 1)

def max_loan_from_emi(max_emi, annual_rate, months):
"""Invert the EMI formula: the largest principal a given EMI can repay."""
r = annual_rate / 12 / 100
return max_emi * ((1 + r) ** months - 1) / (r * (1 + r) ** months)

def lap_eligibility(property_value, ltv, monthly_income, existing_emis,
foir, annual_rate, months):
property_limit = property_value * ltv
max_new_emi = monthly_income * foir - existing_emis
income_limit = max(0, max_loan_from_emi(max_new_emi, annual_rate, months))
eligible = min(property_limit, income_limit)
return {
"property_limit": round(property_limit),
"income_limit": round(income_limit),
"eligible_amount": round(eligible),
"limited_by": "property" if property_limit <= income_limit else "income",
}

print(lap_eligibility(
property_value=10_000_000, ltv=0.60,
monthly_income=150_000, existing_emis=20_000,
foir=0.50, annual_rate=10.5, months=180,
))
For this borrower, the property supports ₹60 lakh, but income supports only about ₹50 lakh, so income is the binding limit. This is exactly the point many borrowers miss: a valuable property alone does not guarantee a large loan. The LTV, FOIR, rate and tenure here are illustrative. Real values come from each lender's credit policy. How do you check whether the document set is complete? A LAP file needs two groups of documents: borrower documents and property documents. A missing document is the most common reason a file stalls, so the first automated check is simply completeness. REQUIRED_DOCS = {
"borrower": {"identity_proof", "address_proof", "income_proof", "bank_statement"},
"property": {"sale_deed", "encumbr...

Powered by Readybot.io

Loan Against Property: Model the LAP Workflow in Python

Blacksmith CI: How GitHub Actions Runners Work

DEV Community

What is Blacksmith CI, and how do its runners work?

What is Blacksmith CI: it is a managed replacement for GitHub-hosted Actions runners, running on bare metal in four of its own regions, which you adopt by installing a GitHub App and changing one runs-on label. Your workflow files, actions, secrets and matrix stay exactly as they are, and what changes is the machine underneath, the cache it talks to, and the per-minute rate. Published Blacksmith Ubuntu x64 tiers, read 20 September 2026, with the standard private-repo GitHub-hosted runner drawn to scale for comparison. Blacksmith does not replace GitHub Actions. It replaces the computer GitHub Actions runs on, which is a much smaller change than the category name suggests and is why adoption is measured in minutes rather than sprints. This page is an explainer, not a pitch. Everything in it was read from blacksmith.sh and docs.blacksmith.sh on 20 September 2026, and where Blacksmith does not publish something, it says so. If you want the head-to-head comparisons instead, Blacksmith against GitHub-hosted runners and Blacksmith against Depot carry the full tables. How it works: an app, a label, and someone else's hardware Adoption is two steps. You install the Blacksmith GitHub App on the repositories that will use it, and you change runs-on from a GitHub label to a Blacksmith one. Nothing else in the workflow moves: uses: steps, marketplace actions, secrets, environments, matrix strategy and artifacts all behave as before, because the runner is still the standard GitHub Actions runner agent, just on a different machine. The GitHub App is not only authentication, and this is the part worth understanding before you roll out. Per the runner docs, Blacksmith watches and provisions capacity for the repositories where the app is installed. A repository that uses blacksmith- labels without the app installed can end up adopting runners provisioned for a different repository, which under-provisions everybody. If jobs queue in an organization that has "no concurrency limits", the missing app install is the first thing to check. Jobs run in one of four Blacksmith regions: Ashburn, Phoenix, Amsterdam and Frankfurt. Nested virtualization is supported on x64 Linux, which is what KVM-dependent jobs and Android emulators need, and is not supported on ARM Linux. jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
The runner sizes, and what the label encodes The label carries the size, the OS and the architecture: blacksmith-{n}vcpu-{os}-{version}, with -arm appended for ARM Linux. Every tier below is published in the instance-types documentation, read 20 September 2026. Label vCPU RAM Storage blacksmith-2vcpu-ubuntu-2404 2 8 GB 80 GB blacksmith-4vcpu-ubuntu-2404 4 16 GB 80 GB blacksmith-8vcpu-ubuntu-2404 8 32 GB 160 GB blacksmith-16vcpu-ubuntu-2404 16 64 GB 750 GB blacksmith-32vcpu-ubuntu-2404 32 128 GB 1.5 TB blacksmith-2vcpu-ubuntu-2404-arm 2 6 GB 75 GB blacksmith-32vcpu-ubuntu-2404-arm 32 96 GB 1.5 TB blacksmith-2vcpu-windows-2025 (public beta) 2 7 GB 130 GB blacksmith-6vcpu-macos-latest 6 24 GB 150 GB blacksmith-12vcpu-macos-latest 12 48 GB 250 GB Two things stand out against GitHub-hosted. The smallest Blacksmith Linux runner has the same 8 GB of RAM as a standard private-repo GitHub runner but 80 GB of disk against 14 GB, which quietly removes a whole class of disk-full failures without any change to your workflow. And ARM tiers carry less memory than their x64 equivalents at the same core count, 6 GB against 8 GB at 2 vCPU, so an ARM migration is not a like-for-like move if your jobs are memory-bound. Ubuntu 22.04 is published at the same sizes as 24.04. macOS runs on Apple silicon M4 with images from macOS 15 through a macOS 27 public beta, and Windows Server 2025 is itself still labeled public beta. Caching: two mechanisms, and they are not the same The first mechanism needs no workflow change at all. Blacksmith ...

Powered by Readybot.io*

Blacksmith CI: How GitHub Actions Runners Work

Building a Live-Streaming Widget with Go and SRS

DEV Community

What I learned building a live-streaming widget with Go + SRS (and one iframe)

I run a small live-selling widget for Shopify and WooCommerce stores. Not a startup — one person, one Go binary, SRS for media. A few decisions turned out to matter more than the feature list, so I'm writing them down. 1. Iframe instead of a script tag The first version was . It injected a floating player into the merchant's page. It broke constantly. Theme CSS leaked in, my CSS leaked out, and every third-party script on the page fought with mine. Worse: the API key sat in the DOM of a public storefront page. What I ship now:
Same iframe, one public shop id, zero secrets in the markup. The merchant pastes four lines. If they break their theme, they don't break the player. Trade-off: iframe can't inherit the store's fonts. For a player that's fine — isolation is worth more than pixel-matching. 2. SRT for the ingest path, not just RTMP RTMP is everywhere and everyone supports it. It's also TCP with fixed latency and handles packet loss badly — which is the normal condition for a merchant streaming from a phone on store wifi. SRT gives you loss recovery and configurable latency for maybe 200ms more delay. For live selling, 200ms of extra delay costs nothing; a frozen frame costs the sale. I still expose RTMP for compatibility, but the default config in my docs is SRT, and the bitrate defaults are deliberately conservative (1500–2000 kbps, 2s keyframe). Cross-border upload can't sustain 6 Mbps and the failure mode is a black player. 3. Enforce limits on the server, never in the widget Viewer caps, stream keys, tier upgrades — all of it lives in the Go backend. The widget only renders. Early on I had a cap check in the frontend so it would "respond faster". It took one person with devtools to watch a 200-viewer room on a 20-viewer plan. Anything that costs money has to be a server-side decision, even if the client shows a cached number. 4. The bug that taught me the most was caching I deploy static assets by scp'ing them into place and restarting nginx. Everything under /static was served with Cache-Control: max-age=604800, immutable. The day I added a feature that needed new i18n keys, every returning user got the new HTML with the old dictionary — and saw raw keys like lc_title on screen instead. Now every asset reference carries a build version. immutable is only safe if the URL changes when the content does. Obvious in hindsight; not obvious at 2am. What's still hard Payments. If you're a solo developer outside the US/EU, merchant onboarding is the real boss fight — not the media pipeline. I've been through two providers' review processes and neither outcome was about code. Feedback welcome The whole thing is at https://durianlive.com — happy to hear what I got wrong, especially from anyone who has run live video at small scale.

Powered by Readybot.io

Building a Live-Streaming Widget with Go and SRS

Prevent Pandas Joins from Multiplying Rows

DEV Community

Catch a pandas join that silently multiplies attendance rows

An attendance table has five check-ins. After a join, it has six rows. Nothing crashed, but the report is already wrong. A duplicated learner ID in the roster produced two matches for one check-in. This fictional example makes that failure visible before building any totals. Run the example All illustrative records are fictional. Save the following as attendance_join.py. The complete script was run in PythonIDE on iPhone; the screenshots show the actual source and output. import pandas as pd

Fictional learner roster and workshop check-ins.

roster = pd.DataFrame({
'id': ['S1', 'S2', 'S2', 'S3', 'S4'],
'name': ['Ari', 'Bo', 'Bea', 'Cy', 'Dee'],
})
visits = pd.DataFrame({
'id': ['S1', 'S2', 'S1', 'S9', 'S3'],
'session': ['AM', 'AM', 'PM', 'PM', 'PM'],
})
print('FICTIONAL ATTENDANCE CHECK')
print('Visit rows before join:', len(visits))
unsafe = visits.merge(roster, on='id', how='left')
print('Rows from unchecked join:', len(unsafe))

Many visits may match one learner, never two identities.

try:
visits.merge(roster, on='id', how='left',
validate='many_to_one')
except pd.errors.MergeError:
print('Guard caught a non-unique roster key.')

Do not choose arbitrarily between conflicting identities.

ambiguous = roster['id'].duplicated(keep=False)
blocked_ids = set(roster.loc[ambiguous, 'id'])
unique_roster = roster.loc[~ambiguous]
report = visits.merge(
unique_roster, on='id', how='left',
validate='many_to_one', indicator=True,
)
report['review'] = report['_merge'].eq('left_only')
print('\nJOINED REPORT')
print(report[['id', 'session', 'name', 'review']].to_string(index=False))
print('\nREVIEW REASONS')
for learner in report.loc[report['review'], 'id'].unique():
reason = 'ambiguous identity' if learner in blocked_ids else 'unknown ID'
print(f'{learner}: {reason}')

Count people, not check-ins. S1 attended twice.

known = report.loc[~report['review']]
print('\nMatched check-ins:', len(known))
print('Distinct matched learners:', known['id'].nunique())
assert len(report) == len(visits)
assert len(known) == 3
assert known['id'].nunique() == 2
print('Row conservation and distinct-count checks passed.')
Understand the checks and their limits validate="many_to_one" states the intended relationship: many check-ins can refer to one learner. A repeated roster key violates that assumption. Rather than keep the first conflicting name, the example quarantines every roster row belonging to an ambiguous ID. A left join then preserves every check-in and indicator=True shows unmatched entries. The actual iPhone run started with five check-ins and produced six rows in the unchecked join. The many-to-one guard caught the duplicated roster key. The guarded report retained five rows, flagged S2 as ambiguous and S9 as unknown, and counted three matched check-ins belonging to two distinct learners. All row-conservation and distinct-count assertions passed. Those are different metrics. This fixture uses nonmissing string IDs; real keys need normalization and missing-key checks. pandas can match null join keys, unlike the SQL behavior many people expect. Try one change Resolve S2 by replacing the two conflicting roster rows with one verified row. Predict matched check-ins and distinct learners. Then add a repeated check-in and decide whether the session plus learner ID should be unique. I build PythonIDE, a Python coding app for iPhone and iPad. The example is intended to teach a workflow you can inspect and adapt. AI assisted with drafting and translation. The example was checked in PythonIDE. PythonIDE on the App Store

Powered by Readybot.io

Prevent Pandas Joins from Multiplying Rows

The Hidden Cost of Uncapped AI Agent Loops

DEV Community

An AI agent with no step limit stops when its context window is full. On GPT-5.6 that's $160 for one run.

The first agent most of us write is a while True loop. Ask the model what to do, run the tool it picks, append the result to the history, ask again. It stops when the model says "done". Usually it does. Sometimes it doesn't: it reads the same file again, retries a failing test forever, or keeps searching for something that isn't there. If you have no step limit, that loop still ends eventually. It ends when the history no longer fits into the model. I simulated how much that one run costs, and the number is set mostly by the size of the context window. A few words first A token is a chunk of text the model reads as one piece; APIs bill per million of them. Input tokens are what you send, output tokens are what the model writes back, and output is priced higher. The context window is the most tokens one request can hold; a request that's bigger is rejected. An agent loop is code that calls the model repeatedly, letting it choose tools, and sends the full history with every call, because the API itself remembers nothing between calls. A step cap is a hard limit on how many times the loop may go around. The setup Every step resends everything so far and adds two things: the model's tool call, which I set at 200 output tokens, and the tool's result. For the result I measured a typical action, "read 100 lines of a file", by cutting four Python standard library modules into 100-line chunks and counting them with tiktoken (o200k_base, the encoding behind current GPT models). The median chunk was 802 tokens, so I used 800. The loop starts with 4,000 tokens of system prompt, tool descriptions and task. So each step makes the next request 1,000 tokens bigger. The loop runs until the next request plus its answer would overflow the window. Prices are official list prices from the catalog of the LLM gateway I work on, snapshot of 12 November 2026, with no discounts applied. Model (window) Steps until full Runaway run With a 25-step cap GPT-5.6 mini (400K) 396 $16.05 $0.09 GPT-5.6 (400K) 396 $160.54 $0.86 Claude Haiku 5.5 (1M) 996 $50.05 $0.04 Claude Sonnet 5.5 (1M) 996 $1,000.98 $0.85 Claude Opus 5.5 (1M) 996 $2,001.96 $1.70 On GPT-5.6 the runaway run costs 187 times what a capped one does. On the 1M-token models it's 1,178 times. The last step alone on Sonnet 5.5 costs $2, more than the whole capped run. Why the window sets the price Step n sends about n thousand tokens, so the total is 1 + 2 + 3 + … up to the last step. That sum grows with the square of the number of steps, and the number of steps is the window divided by the growth per step. Put together, a runaway run sends roughly window² ÷ (2 × tokens added per step) input tokens: 400,000² ÷ 2,000 = 80 million on GPT-5.6, almost exactly the 79.8 million the simulation counted. That's why a bigger window is a bigger risk. At the same $2/$12 price, I ran the loop with three window sizes: 200K costs $40.26, 400K costs $160.54, 1M costs $1,001.38. Doubling the window made the run 4.0 times more expensive; 2.5 times the window made it 6.2 times more. A model upgrade that "only" raises the window from 200K to 1M multiplies your worst case by 25 without touching the price per token. The fix is two counters Count steps, and count dollars from the usage field every response already returns. Here is the guard, written for OpenAI-style responses: PRICE_IN, PRICE_OUT = 2.00, 12.00 # $ per 1M tokens, your model's list price
MAX_STEPS, MAX_USD = 25, 1.00

def run_agent(client, model, messages, tools, execute_tool):
spent = 0.0
for step in range(MAX_STEPS):
r = client.chat.completions.create(model=model, messages=messages, tools=tools)
u = r.usage
spent += (u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT) / 1e6
msg = r.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content, spent
if spent >= MAX_USD:
raise RuntimeError(f"budget hit after {step + 1} steps: ${spent:...

Powered by Readybot.io

The Hidden Cost of Uncapped AI Agent Loops

Platform Engineering 2026: IDP Tools and Trends

DEV Community

Platform Engineering 2026: Internal Developer Platforms – Between Open Source and SaaS

Platform engineering has evolved over the past two years from a conference buzzword into a strategic discipline. At its core, it’s about a simple question: How do development teams get a self-service infrastructure platform without every team having to become a Kubernetes expert? The answer is an Internal Developer Platform (IDP) – and in 2026 the market is more dynamic than ever. CNCF Technology Radar: The Data The CNCF Q1 2026 Technology Radar, published in March 2026 based on a survey of over 400 professional developers, paints a sobering picture: only 28 percent of organizations have a dedicated platform engineering team. The majority – 41 percent – rely on loose multi-team collaboration, which in practice means the platform remains a shared side project without a fixed owner. 35 percent report a hybrid platform that combines existing developer portals with specialized AI tools. On the tooling side, the picture is clearer. Backstage, Helm and k9s are in the Adopt status for application delivery. Helm achieves the highest maturity rating: 94 percent of developers gave four or five stars for reliability. GitHub Actions scores 91 percent recommendation, and cert-manager 87 percent for stability. Backstage: The Market Leader Backstage, open-sourced by Spotify in 2020 and donated to the CNCF, remains the gold standard. With over 34,000 GitHub stars and more than 200 community plugins, it forms the backbone of most IDP strategies. The catch: Backstage is a framework, not a finished product. Every team that uses it operates its own React/Node application, writes its own plugins, and keeps up with the release pace of versions like v1.55.3 (September 2026). The total cost consideration puts the supposedly free license into perspective. Third-party analyses estimate the actual annual cost for a medium-sized Backstage installation at around $150,000 – driven by engineering time for integration, plugin development, and operations. So if you’re comparing Backstage against a SaaS alternative, you shouldn’t compare with $0, but with that figure. The Commercial Competition: Port, Cortex, and OpsLevel Against the open-source option, three commercial providers have positioned themselves, gaining massive maturity in 2025 and 2026. Port has evolved from managed Backstage hosting into an “Agentic SDLC” company. The launch of Port AI Builder in July 2026, a natural-language interface for agentic workflows, marks the most significant AI push in this segment so far. Pricing is transparent: Basic from $30 per seat per month (up to 50 seats), Standard from $40 (up to 200 seats). Cortex focuses on scorecards and engineering leadership. Instead of starting with a catalog interface, Cortex builds around evaluating services against maturity criteria – security patching, test coverage, on-call readiness. The entry price is around $65 per user per month (Amazon Marketplace reference for 50 users: $39,000 annually). OpsLevel competes directly with Cortex but differentiates through deep CI/CD and deployment integration. Instead of initiative tracking, it focuses on supply chain metrics: build times, deployment frequency, and change failure rate. The Build vs. Buy Dilemma Platform engineering is not a pure technology decision. If you build, you save the license but pay in engineering time. A rough three-year calculation for a 150-person team shows: Backstage (self-hosted) costs cumulatively about $390,000, Port (Standard tier) $216,000, Cortex (Marketplace reference) $351,000. Port’s published prices give the cheapest three-year outlook in the model – though without considering migration costs if you switch later. However, the truly critical factor is not the price, but the internal organization. If you don’t have a dedicated platform team, Backstage is often not an option – operating your own React/Node application plus plugin maintenance overtaxes multi-team structures. Commercial offerings provide faster time-to-value here, bought at the cost of vend...

Powered by Readybot.io

Platform Engineering 2026: IDP Tools and Trends

How to Get Your MCP Server Found on Google

DEV Community

How to make your MCP server show up in Google

You built an MCP server, pushed it to the registry, maybe wrote a GitHub README — and when you search for it, nothing. Or worse: a stale third-party page ranks instead of you. MCP servers are weirdly hard to find in Google, and the reasons are fixable. Here's what actually moves the needle. Why MCP servers are invisible The registry is a JSON feed, not a web page. The official MCP registry is machine-readable. Google indexes pages, and a JSON entry isn't one. GitHub repos rank for the repo name, not intent. Your repo might surface if someone searches your exact name, but not for "MCP server for X" — and external links in a GitHub README are nofollow, so your repo doesn't pass authority to your own site. No dedicated page means nothing to rank. If the only "page" about your server is a registry row and a repo, there's nothing for Google to show for a discovery query. What actually helps 1. Give your server a real, indexable page A landing page or docs page on your own domain, with a clear title and a 100+ word description of what the server does and how to install it. This is the page you want to rank. One page done well beats a thin repo. 2. Write a description that matches how people search Registries and directories pull your description. Write it for a human searching "MCP server for Postgres" or "Stripe MCP server," not for a changelog. Lead with the capability, name the integration, keep it under ~160 characters for the meta description. 3. Get listed where the listing is actually indexable Most MCP directories exist, but many of their pages carry a noindex tag until a listing is claimed or hits a quality bar — so being "listed" isn't the same as being indexed. Check the real page: view source and look for . If it's there, that listing isn't helping you in search. On Serverdex, for example, every server in the registry already has a page, but unclaimed ones are noindex until you claim them. Claiming your listing (free, one GitHub click) flips the page to indexable, adds it to the sitemap, and gives you a dofollow link back to your site — the kind of link that actually helps your own domain rank. 4. Earn a couple of real links A dofollow link from a relevant, indexed page is worth more than ten nofollow mentions. A short dev.to post about your server, a PyPI package with your homepage set, a mention from a blog that covers MCP — those are the links Google counts. GitHub stars and nofollow README badges feel good but do not move search rankings. 5. Don't wait for the registry to do it for you The registry's job is distribution to clients, not SEO. Search visibility is on you: one good page, an honest description, an indexable listing, and a few real links. The quick version One real page on your domain — 100+ words, clear title. A description written for search intent. A listing that is actually indexable (check for noindex). A couple of dofollow links from indexed pages. Ignore nofollow vanity metrics. If you want to check whether your server already has an indexable page — and claim it — the whole registry is searchable at Serverdex.

Powered by Readybot.io

How to Get Your MCP Server Found on Google

Why Notifio’s Comparison Pages Aren’t Real Tables

DEV Community

49 rows of comparison, zero table elements, and 811 words of it carrying five full stops in total

I pulled all 38 pages of notifio.app and counted words and sentences in the visible text, to see where the site's weight actually sits: 36,947 visible words 2,001 sentences 18.5 words per sentence on average thickest page: /terms at 2,440 words thinnest page: /download at 149 words, with /pricing second at 344 That last pair is worth a post of its own some day, because the two pages that have to actually sell something are the two thinnest on the site. But the thing that stopped me was the sentence-length table. The longest sentence on notifio.app, by my splitter, was 202 words. The next three were 191, 168 and 150. The fifth was 141 words and sat in the terms of service, which is where you expect to find a 141-word sentence. The top four were not sentences. They were the comparison tables, and they are not tables. There is no table in the comparison table $ curl -s https://notifio.app/compare/rentbird | grep -c '<table'
0
$ curl -s https://notifio.app/compare/rentbird | grep -c 'role="'
0
No , no , no role="table", no . The side-by-side on all five comparison pages is a CSS grid of div and p elements, 13 grid containers on the page. I counted the rows and their honesty flags last month in 49 rows in our comparison tables, 13 of them say the competitor wins. I counted the argument prose in a required field made us concede on every comparison page. Both times I read the data and never the markup carrying it. Measured per page, taking the text of just the side-by-side section: Page Rows Words in the section Sentence-ending full stops /compare/rentbird 10 196 1 /compare/visualping 11 185 1 /compare/rentslam 10 162 1 /compare/stekkies 10 144 1 /compare/rentola 8 124 1 811 words, 49 rows, and five full stops between them. Each page's single stop is the caption underneath: "Brighter text marks whichever side we think genuinely wins that row, including the rows where it is not us." Flattened, the Rentbird one begins like this: Rentbird Notifio Price Rentbird: €29 for one month, €49 for three Notifio: £20 once, forever What happens when you stop paying Rentbird: Alerts stop Notifio: Nothing, since the licence does not expire The rival's name appears twelve times in the extracted text of a ten-row table. That is not a bug in my extractor. It is two labelling systems in one document. Why it is divs, and why the divs are not lazy // src/app/compare/[target]/page.tsx

 

{target.name}

Notifio

function Cell({ label, value, won }: { label: string; value: string; won: boolean }) {
return (


{${label}:}
{value}


);
}
There are two sets of labels and each is switched off at the breakpoint where the other works. On a wide screen you get a column header row and the per-cell prefixes are display: none. On a phone the header row is display: none and each cell introduces itself, "Rentbird: €29 for one month", which is the only way a three-column row survives being stacked vertically. 98 of those hidden prefixes exist across the five pages. And the accessibility of that is better than I expected when I started writing this. Tailwind's hidden is display: none, and display: none is hidden from assistive technology, not just from eyes. So a screen reader at desktop width hears the column header and no prefix; at phone width it hears the prefix and no header. The labels follow the breakpoint. This is not the classic bug where a visually hidden label gets announced on top of a visible one. What is actually lost Two things, and the second one is the one I had not thought about. Table semantics. Without , a screen reader user gets 49 paragraphs in a row. There is no "column 2 of 3", no row navigation, no way to walk down one product's column and hear only its answers. On...

Powered by Readybot.io

Why Notifio’s Comparison Pages Aren’t Real Tables