Edge-Native i18n, Part 4: The Engine Becomes an npm Package, and the Docs Become an Agent Skill

Globe of edge nodes feeding an npm package box and a SKILL.md agent skill, the two halves of the edge-native Astro i18n engine in Part 4
#astro #cloudflare workers #i18n #agent skills #claude code #npm #system design

Edge-Native i18n with Astro & Cloudflare Workers - Part 4

@edgekits/astro-i18n went from 0.1.0 to 0.1.4 in two days. Two of the four releases in between were packaging polish. The other two fixed real bugs, and neither of those came from my own code review. Each was found by a coding agent consuming the package through its skill: first on a fresh site, then on the rebuilt demo repo, which in turn exposed the same bug on the site that published the skill in the first place.

This is Part 4 of the series, and it is the first part where the reader of the README is not a person.

What changes when an agent, not a developer, installs your Astro i18n

Part 1, Part 2 and Part 3 explained how the engine works: translations as data in Workers KV, a per-namespace edge cache, typed keys generated from the default locale, and a Purge API step that updates copy without a redeploy. Every one of those articles ended the same way: clone astro-edgekits-core, copy src/domain/i18n into your project, wire the scripts, go.

That copy step is what this part removes. The engine is an npm package now. The instructions for installing it are an agent skill, and the thing that reads them is Claude Code or whatever coding agent you run.

I still wrote a README. I just stopped writing it for you.

TL;DR

  • The i18n engine from Parts 1 to 3 ships as @edgekits/astro-i18n: integration, middleware, KV fetcher, purge CLI, headless components.
  • An agent skill installs it. It asks six questions, follows the README in order and checks its own work against a checklist.
  • Five releases in two days. The two real bugs were found by agents consuming the skill, one of them on edgekits.dev itself.
  • The demo repo was rebuilt by the skill in one session, with a Deploy to Cloudflare button that provisions KV.
  • What is still not agent-proof: silent fallbacks on an empty KV, the purge baseline, and a session restart per skill update.

Why the hand-copied engine had to become an npm package

The reason was not elegance. It was that I needed the same engine in two kinds of project at once, and copying files between them does not survive the second week.

The first kind is this site: Astro, output: 'server', no React since the move to pure Astro, zero client JavaScript on content pages. The second kind is what the starter kits announced on this site need: a marketing site on one origin with the same zero-JS rules, and an app on another origin where React islands are allowed because the forms and dashboards earn them. Both need the same locale routing, the same KV layout and the same purge step, or the two halves of one product drift apart in their translations.

With a hand-copied engine, every fix I made on edgekits.dev had to be copied into the demo repo, and from the demo repo into whatever the next project was. The demo had already fallen behind: it was still on Astro 5 and @astrojs/cloudflare 12 while the site had moved to Astro 7 and adapter 14. The engine the articles described and the engine in production were two different trees.

So the extraction was the boring kind. src/domain/i18n left the site with its git history, became EdgeKits/astro-i18n, and the site became the package’s first consumer. The site’s own decision record for that step says it in one line: the engine is the package, and this site consumes it.

One engine, two sites, two branches

The demo repo makes the two-kinds-of-project argument concrete. Its main branch is the zero-JS variant: no UI framework, no hydration directive anywhere, a newsletter form that is a plain Astro form over an action with a vanilla progressive-enhancement controller.

Its react-islands branch is main plus exactly one commit. That commit adds @astrojs/react, react, react-dom and react-hook-form, and replaces the newsletter form with a React island. git diff --stat main..react-islands lists twelve files: the integration in astro.config.mjs, the island, the deleted .astro form and its controller, the dependencies, tsconfig.json for JSX, and the two docs that explain the branch.

Git graph of astro-edgekits-core: a zero-JS main line and a react-islands branch of exactly one commit that is rebased onto main, never merged

The branch is rebased on main after every change, never merged. That is the maintenance rule written into the repo’s agent instructions, and it is the whole point: the React variant is one commit of difference, not a fork.

What lives in the package and what stays project code

The split follows a question I asked for every file: does this change when the project changes, or when the engine changes? The complete guide to Astro i18n describes the engine’s moving parts in full; here I only sort them.

Two columns splitting the Astro i18n engine: node_modules holds routing middleware, KV fetcher, CLI and headless components; the project tree keeps styling, country-to-locale mapping, markdown redirects and the generated schema

Engine files went into the package. Project files stayed project files, and the skill writes them for each new project. The package’s entry points say the same thing from the runtime’s side:

EntryRuns inHolds
@edgekits/astro-i18nastro.config, NodeedgekitsI18n() (the integration), defineI18nConfig
@edgekits/astro-i18n/configanywhere, Node-safedefineI18nConfig, localesOf(config), the config types
@edgekits/astro-i18n/formatanywhere, Node-safefmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath
@edgekits/astro-i18n/runtimeworkerd onlyfetchTranslations, locale constants, locale resolution
@edgekits/astro-i18n/middlewareworkerd onlythe i18n middleware, registered for you unless middleware: 'manual'
@edgekits/astro-i18n/components/*.astro pagesLanguageSwitcher, MissingTranslationBanner

The Node-safe entries import nothing from cloudflare:workers and no virtual module, so unit tests under Node and src/content.config.ts can use them. The workerd entries need the integration’s Vite setup and the Workers runtime. That boundary used to be a convention inside one repo. In the package it is enforced by a test that checks every runtime import against an allowed list.

The integration replaces the glue you used to copy

In Part 1 the engine reached project values through imports of src/config/project.ts and a hand-maintained src/i18n.base.d.ts. In the package, one config file replaces that:

// edgekits-i18n.config.ts
import { defineI18nConfig } from '@edgekits/astro-i18n/config'

export default defineI18nConfig({
  languages: {
    en: { countryCode: 'us', name: 'English' },
    de: { countryCode: 'de', name: 'Deutsch' },
    es: { countryCode: 'es', name: 'Español' },
    ja: { countryCode: 'jp', name: '日本語' },
    'pt-br': { countryCode: 'br', name: 'Português (Brasil)' },
  },
  defaultLocale: 'en',
  cookie: { name: 'lang', maxAge: 60 * 60 * 24 * 365 },
  keyPrefix: 'astro-edgekits-core',
  missingTranslationBanner: true,
  routing: 'prefix',
})

Both the integration and the CLI read it. The integration serves it to the runtime as a virtual module, together with the files the CLI generates, sets vite.ssr.noExternal for the package (the runtime ships as source so Vite compiles the .astro components in your program), fills Astro’s own i18n block when astro.config has none, and narrows the package’s Locale type to your five locales through an augmentable interface. The project’s astro.config.mjs is three lines longer than before the package existed:

import { edgekitsI18n } from '@edgekits/astro-i18n'
import i18nConfig from './edgekits-i18n.config'

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
  integrations: [edgekitsI18n(i18nConfig)],
})

Headless components, because the package cannot know your CSS

LanguageSwitcher and MissingTranslationBanner ship with no stylesheet, and they have no opinion about how you style them. Each takes a classes map with one slot per element (root, trigger, dropdown, item, itemActive and so on), and whatever string you put in a slot lands on that element’s class attribute. A slot you leave out renders no class attribute at all.

Use Tailwind if your project uses Tailwind: pass utility classes per slot, as edgekits.dev does. Use vanilla CSS if you prefer: every element also carries a stable data-* hook, rendered whether or not you pass classes, so [data-ls-item] or [data-ls-dropdown][hidden] in your own stylesheet works just as well, and so does a script that wants to find the menu. The package ships no CSS and needs no Tailwind.

On edgekits.dev the switcher’s classes live in the site’s Header.astro, so Tailwind finds them in the site’s sources and global.css needs no @source line pointing into node_modules. That is the Tailwind-specific part of the story, and it is a property of the site, not of the package.

The same rule covers policy. Which country maps to which locale (countryToLocale), whether markdown endpoints redirect to one locale (markdownLocale), what the banner says in each mode: all of it is config or props. The package decides mechanics, the project decides policy, and nothing in the package has an opinion about your markup.

Generated files belong to the consumer

The CLI writes four files into the project: i18n-data.json (the KV payload), src/i18n.generated.d.ts (the schema, so t.landing.hero.title is typed), src/i18n/runtime-constants.ts and src/i18n/fallbacks.generated.ts (the default locale compiled in, served when KV is unreachable). They are committed. They are regenerated after every JSON edit. They are never edited by hand.

That was true in Part 1 too, with one difference: in the package these files are the only thing the engine leaves in your tree. Everything else is node_modules.

The agent skill is the installation interface

Here is the part that changed my mind about documentation. The package README is thirty-six kilobytes. Nobody is going to read it, and nobody should have to. The thing that reads it is the skill.

The skill is a folder in the package repo, plugins/astro-i18n, with a SKILL.md under 8,000 characters (a test enforces the limit) and three reference files it reads on demand: forms.md, seo.md and checklists.md. The skill’s version tracks the package’s version, also test-enforced, so the agent that installs 0.1.4 reads the instructions for 0.1.4.

Its first rule is the one I would want any contractor to follow: the README is the installation and configuration reference, read it, follow it, do not restate it. The skill adds what a README cannot: the order of operations, the questions to ask before touching anything, and the checks to run after.

The six questions it asks before writing a line

The skill does not start with code. It starts with a conversation, and it waits for the answers:

  1. Which locales, and which is the default? Codes are lowercase, pt-br, never pt-BR.
  2. Is this an Astro project on @astrojs/cloudflare? If there is no project, the skill says so and stops. It does not scaffold Astro.
  3. Does the site serve markdown endpoints (/blog/x.md, llms.txt twins), and in which locale? That becomes markdownLocale.
  4. Should a visitor’s country pick the locale when there is no cookie and no Accept-Language match? That becomes countryToLocale.
  5. Middleware: auto, or manual because the project already has a src/middleware.ts sequence to slot into?
  6. Flags in the switcher? A regional locale takes its region (pt-br to br); for a language-only locale the skill proposes the obvious country and asks for confirmation, then shows the full list back before writing the config.
Six questions the astro-i18n agent skill asks before writing code: locales and default, Astro on Cloudflare, markdown endpoints, geo routing, middleware mode, switcher flags

Each question maps to a config field or a file the skill will write. The flags question exists because the first version of the skill did not ask it: it set a countryCode for every locale without anyone choosing one, and nothing decided which flag a regional locale like pt-br should get. That is 0.1.1, the first entry in the table further down.

What the skill refuses to do

A skill for coding agents is as much a list of refusals as a list of steps. These are the ones in SKILL.md, and every one of them earned its place:

  • It does not scaffold Astro. The package is Workers- and KV-specific, and without the Cloudflare adapter there is nothing to install into.
  • It does not create a KV namespace without asking. wrangler kv namespace create touches your Cloudflare account. Until you say yes, local development runs on any 32-character hex id, which wrangler treats as a folder name under .wrangler/state/.
  • It does not edit generated files by hand, and it does not “fix” a .i18n-hashes.json that migrate refuses to run against. That file records what production KV holds; the error message explains both cases, and the skill asks you which command last wrote it.
  • It does not copy engine code into the project. If the package lacks something, it says so instead of re-implementing it.
  • It never imports @edgekits/astro-i18n/runtime from a module Node loads: astro.config, the i18n config, content.config.ts, unit tests. Those get localesOf(config) from ./config and the helpers from ./format.
  • It stages secrets by name and never with git add -A, after git check-ignore on the file it is about to write.
Four refusals written into the agent skill: no Astro scaffolding, no KV namespace without permission, no hand-edited purge hashes, no secrets staged with git add -A

The refusals are the difference between a skill and a tutorial. A tutorial trusts the reader to know when a step does not apply. A skill has to say it.

Installing it from a Claude Code plugin marketplace

The skill ships as a Claude Code plugin, listed in the EdgeKits marketplace with a git-subdir source pinned to the package’s release tag. Installing it is two commands:

claude plugin marketplace add EdgeKits/skills
claude plugin install astro-i18n@edgekits --scope project

--scope project matters more than it looks. It records the marketplace and the enabled plugin in the repository’s .claude/settings.json, so the project itself declares which skill its agents use. A fresh clone needs one install command, and CLAUDE.md on edgekits.dev now has a rule that any task touching localization starts by loading the skill, with the site’s own CLAUDE.md winning where the skill’s generic advice conflicts.

The marketplace entry is the part people underestimate. Pinning ref to v0.1.4 means the plugin a user installs is the plugin that shipped with 0.1.4 of the package, byte for byte. Bumping the pin is a one-line commit per release, and it is the last step of every release I describe below.

What the skill writes into your project, in code

The package ships mechanics. The skill writes the project code around them, and the demo repo is where you can read every line it wrote. Four of those files are the ones the bugs above were about, so they are the ones worth showing.

The action that accepts the form

The newsletter action takes accept: 'form', so the same handler serves the native browser post and the enhanced submit. The body in demo mode is deliberately empty; the comment says where a store goes.

// src/actions/newsletter.ts
import { ActionError, defineAction } from 'astro:actions'
import { env } from 'cloudflare:workers'
import { LocaleSchema } from '@edgekits/astro-i18n/runtime'

import { ERROR_MESSAGE_CODES } from '@/domain/messages'
import { NewsletterSchema } from '@/domain/newsletter/schema'

export const newsletter = {
  subscribe: defineAction({
    // FormData both ways: the no-JS native POST and the enhanced submit.
    accept: 'form',
    input: NewsletterSchema.extend({
      // The UI locale, for locale-aware server work (the confirmation email's language).
      locale: LocaleSchema,
    }),
    handler: async () => {
      // DEMO_MODE=on: the input is validated and nothing is stored.
      if (env.DEMO_MODE === 'on') return { ok: true as const }

      // A real deployment stores the subscriber here (a D1 row, a KV key or an email
      // provider's API) and reports expected failures as codes:
      // throw new ActionError({ message: 'EMAIL_TAKEN', code: 'BAD_REQUEST' }).
      throw new ActionError({ message: ERROR_MESSAGE_CODES.INTERNAL_SERVER_ERROR, code: 'INTERNAL_SERVER_ERROR' })
    },
  }),
}

The schema that turns null into REQUIRED

Astro’s FormData coercion hands the action null for an empty field and true for a present checkbox. The schema has to say what those mean, in codes, or the no-JS path prints zod’s English.

// src/domain/newsletter/schema.ts
import { z } from 'astro/zod'

import { ERROR_MESSAGE_CODES } from '@/domain/messages/error-codes'

// Server-side source of truth; every message is a CODE, localized through `messages`.
// The browser validates with a hand-rolled mirror, never with zod.
export const NewsletterSchema = z.object({
  // Astro's FormData parsing turns an empty field into null: the type error is REQUIRED too.
  email: z
    .string({ message: ERROR_MESSAGE_CODES.REQUIRED })
    .trim()
    .min(1, { message: ERROR_MESSAGE_CODES.REQUIRED, abort: true })
    .pipe(z.email({ message: ERROR_MESSAGE_CODES.INVALID_EMAIL })),
  // A checkbox: Astro's FormData parsing turns presence into `true`.
  consent: z.boolean().refine((value) => value, { message: ERROR_MESSAGE_CODES.CONSENT_REQUIRED }),
})

Two invariants from Part 2 survive unchanged here. Validation and server layers emit codes, never human text, and the messages namespace holds the text for every code in every locale. And zod stays on the server: its new Function() JIT breaks a page served without 'unsafe-eval' in its CSP, so the client validates with a small hand-written mirror. The mirror can disagree with zod on an edge case, and that is fine, because the server’s answer is rendered the same way.

The list that shows every post

A blog index in a locale with missing translations lists every post of the default locale, substitutes the translation where one exists, and marks the rest. The helper is eleven lines:

// src/lib/blog.ts
export async function getPostsWithFallback(
  locale: string,
  defaultLocale: string,
): Promise<{ post: Post; isFallback: boolean }[]> {
  const published = await getPublishedPosts()
  const translated = new Map(
    published.filter((post) => splitId(post.id).locale === locale).map((post) => [splitId(post.id).slug, post]),
  )
  return published
    .filter((post) => splitId(post.id).locale === defaultLocale)
    .map((base) => {
      const post = translated.get(splitId(base.id).slug)
      return post ? { post, isFallback: false } : { post: base, isFallback: true }
    })
    .sort((a, b) => b.post.data.pubDate.valueOf() - a.post.data.pubDate.valueOf())
}

The index page sets Astro.locals.isMissingContent when any listed post is a fallback, which turns the banner on in content mode, and links every card to the URL locale’s path. The post page does the falling back.

The fallback post that canonicalizes instead of hiding

The first draft of the rebuild put noindex on a fallback post. That is what the Part 1 demo did, and it is what I told the agent to do. edgekits.dev had stopped doing it weeks earlier: noindex next to a canonical sends search engines two signals that contradict each other, and a page with noindex and no canonical is the weakest page there is. The rule now, in the skill and in the demo, is a cross-locale canonical and no robots meta at all.

---
// src/pages/[lang]/blog/[...slug].astro (frontmatter, trimmed)
const altLocales = (await getPostLocales()).get(slug) ?? []
const contentLocale = altLocales.includes(uiLocale) ? uiLocale : DEFAULT_LOCALE
const post = altLocales.includes(contentLocale) ? await getEntry('blog', `${contentLocale}/${slug}`) : undefined
if (!post) return Astro.rewrite(`/${uiLocale}/404/`)

const isFallback = contentLocale !== uiLocale
Astro.locals.isMissingContent = isFallback
const canonicalOverride = isFallback
  ? `${siteOrigin(Astro.site, Astro.url)}/${DEFAULT_LOCALE}/blog/${slug}/`
  : undefined
---

<Base title={post.data.title} altLocales={altLocales} canonicalOverride={canonicalOverride}>

The layout takes canonicalOverride and falls back to the page’s own URL. The hreflang set is altLocales, the locales the post exists in, plus x-default. The sitemap lists only post files that exist, so a fallback URL is reachable, canonicalized, and never advertised.

Five releases in two days, and who found each bug

The table is the article. Everything above it is why the table could exist.

VersionFound byWhat changed
0.1.0the extraction itselfEngine out of the site with its history; integration, CLI, headless components, the skill; audit fixes: negotiated redirects are 302 and uncacheable
0.1.1the first install on edgekits.devcountryCode optional; the skill asks about flags and moves pages under src/pages/[lang]/; the “Using the skill” guide; prepublishOnly build
0.1.2npm publish warningspackage.json normalised so publishing prints no warnings; the build wipes dist/ so a deleted source cannot ship as a stale compiled file
0.1.3the first fresh site built with the skillThe lang cookie is no longer httpOnly; an invalid cookie is corrected; arrays in translation JSON are typed; flags served from public/
0.1.4the agent that rebuilt the demo repoSkill only: the form action takes accept: 'form'; empty fields report REQUIRED; no-JS errors are localized; list pages show every post; canonical over noindex

0.1.1 and 0.1.2 are the kind of thing a first publish always turns up. 0.1.3 and 0.1.4 are the reason I am writing this part.

Two diagrams of the bugs agents found: an httpOnly lang cookie blocking the client switcher, and a no-JS form POST rejected with 415 because the Astro action lacked accept form

The first site I built with the skill was a throwaway: four locales, a contact form, a switcher with flags. The agent asked its six questions, installed the package, moved the pages, seeded KV, and the site came up. Then I clicked German in the switcher and nothing happened.

The package set the lang cookie from the server with httpOnly: true, as a server-set cookie should be. The switcher writes the same cookie from the browser, because the switcher is where the preference is born. An httpOnly cookie cannot be overwritten by document.cookie, so once a stale value was there, from the site’s own dev server on the same host, the switcher’s write was silently dropped and the middleware kept redirecting to the old locale.

Nobody saw it on edgekits.dev because edgekits.dev had set that cookie long before the package existed and never had a stale one. The fix in 0.1.3 drops httpOnly, and the middleware now corrects a cookie whose value is not one of the configured locales to the URL’s locale on the next request. A request without the cookie still gets no Set-Cookie.

The same test found two more. JSON arrays in the translation files were typed as string in the generated schema, which made a list of feature cards uncheckable. And the flags recipe inlined the SVGs from flag-icons into every page, which for one flag alone was about eighty kilobytes of markup. 0.1.3 types arrays from their first element and serves flags from public/flags/<code>.svg through an <img>.

The 415 nobody saw

The second real bug came from the demo repo. I had the skill rebuild astro-edgekits-core from scratch on the package: five locales, a landing page, a blog with a deliberately untranslated post, a newsletter form, hreflang, sitemap, RSS, llms.txt. The agent did it in one session and six commits.

In its report it flagged a deviation from the skill’s form recipe. The recipe’s action example had no accept: 'form'. Under Astro 7, an action without that option accepts JSON only, so a native HTML form post, the path a browser takes with JavaScript off, gets 415 UNSUPPORTED_MEDIA_TYPE with the body This action only accepts JSON. The agent added accept: 'form' to make the no-JS path work, and wrote it down as the first decision record of the new repo.

I read that and sent a FormData POST to the newsletter action on edgekits.dev. Same answer: 415, This action only accepts JSON. With JavaScript on, the actions client sends JSON and the form works, which is why nobody had noticed. With JavaScript off, the site’s newsletter form had never worked, and the site’s own form was written from the same recipe the skill now teaches.

The fix landed in three places. The skill’s forms.md now requires accept: 'form' on the action and new FormData(form) on the enhanced submit, maps an empty field (which Astro coerces to null) to the REQUIRED code instead of zod’s untranslated type error, and resolves no-JS field errors through the messages namespace so a raw code never reaches the page. That is 0.1.4, a release with no runtime change at all.

The site got the same change, three new tests, and a REQUIRED key in four locales pushed to production KV with one migrate. The demo repo was already right.

Why does “found by an agent” matter more than “fixed”?

Two things stand out in that table, and neither is “agents are good at finding bugs”.

The first is that the bugs were in the gap between the engine and its instructions. The engine worked on edgekits.dev for months. What broke was the recipe for installing it somewhere else, and a recipe is only tested when somebody follows it exactly. An agent follows it exactly.

The second is that the loop closed on its own author. The skill found a defect in the site that published the skill, because the site was written from the same recipe. A hand-copied engine never gets that check. Its instructions are tested once, by the person who wrote them, on the project they came from.

Rebuilding the demo repo with the skill in one session

The demo rebuild deserves its own section because it is the thing a reader can clone and verify, and because the repo is now an agent-ready starter in a way the Part 1 version was not.

The old main was the Part 3 code: Astro 5, React islands, shadcn, a hand-copied engine, a 1,500-line README that explained how to copy it. The new main is a consumer of the package, and the agent built it by running the skill against an empty Astro 7 scaffold with the six answers I gave it up front. The old architectures did not disappear. v1-version-based-cache holds what Parts 1 and 2 describe, and v2-purging-cache holds what Part 3 describes, frozen, because the articles link to them.

What the agent built from six answers

The landing page kept its copy from the old locales/ folder; the agent corrected the facts in it (Astro 5 became Astro 7, React and shadcn left the “built with” line). The blog runs on an Astro content collection with two posts. One of them exists only in English and German on purpose.

Open that post in Spanish, Japanese or Portuguese and you get the English post with the banner and a rel="canonical" pointing at the English URL, no noindex. The list page shows every post in every locale and flags the untranslated ones. Both rules came from edgekits.dev’s own decision records, and both went back into the skill’s seo.md in 0.1.4 because the first draft of the rebuild had them wrong.

The newsletter form is an Astro form over an action in demo mode: it validates, answers, and stores nothing, with a comment saying where a subscriber store would go. robots.txt disallows crawling while demo mode is on. The SEO suite is project code the skill writes: <html lang> through toIetfTag, hreflang with x-default, a sitemap listing only post files that exist, one RSS feed per locale, llms.txt.

AGENTS.md, CLAUDE.md and the plugin declaration

The repo ships three files that make it agent-ready rather than just agent-built. AGENTS.md holds the public instructions: what the repo is, the branch rules, where locales and generated files live, the invariants (lowercase locale codes, no framework on main, flags from public/, secrets never in git), and the rule that any localization task starts with the skill. CLAUDE.md is one line, @AGENTS.md, an import. And .claude/settings.json declares the enabled plugin, so the first agent session in a fresh clone already knows which skill to load once the marketplace is registered.

Three files that make a repository agent-ready for Claude Code: AGENTS.md with the rules, a one-line CLAUDE.md importing it, and .claude/settings.json declaring the skill plugin

The one-line CLAUDE.md is not cosmetic. Claude Code reads AGENTS.md on its own only when no CLAUDE.md or CLAUDE.local.md exists above the working directory. I keep a private CLAUDE.local.md for my own session protocol, and its presence alone would switch the agent to CLAUDE.md mode and skip AGENTS.md. The import makes both files load regardless.

Can the Deploy to Cloudflare button provision KV from placeholder ids?

The README banner was always a Deploy to Cloudflare button. I had dropped the link during the rewrite because I assumed a repository with placeholder KV ids could not be deployed with one click. I was wrong, and the test proved it.

The Deploy to Cloudflare flow reads wrangler.jsonc from the source repository, provisions the resources it declares, writes the new ids into the copy it creates in your account and deploys it through Workers Builds. The source repository must carry placeholder values for the ids, which is exactly what the starter does: thirty-two zeros, the shape the package CLI requires for local work.

Deploy to Cloudflare flow for the Astro i18n starter: wrangler.jsonc with 32-zero placeholder ids, KV namespace provisioned, Workers Build deployed

I pressed the button on the published repo. It created a Worker and a KV namespace, built, and deployed. The site came up on a workers.dev URL with the root redirecting to a locale, five flags in the switcher, the fallback post canonicalizing to its English URL, seventeen entries in the sitemap, and the newsletter action answering 400 with codes to a FormData post.

The English copy showed in every locale, because KV was empty and the compiled-in fallbacks were serving. One migrate against the new namespace id fills it. Then I deleted the Worker, the namespace and the repo copy, because the demo is not deployed anywhere by design.

What is still not agent-proof

The column missing from the table is the one that matters most: the things an agent cannot see, and the skill cannot ask about.

Three blind spots of an agent installing the i18n engine: silent fallback dictionaries on an empty KV, a purge baseline that needs real Cloudflare tokens, and prose instructions that drift

The fallbacks are silent. When KV is empty or unreachable, every locale renders the default locale’s text from the compiled dictionaries, and no banner appears. The banner is driven by a build-time constant that records which locales have JSON files, not by what KV returned. That is the right behaviour for an outage, and the wrong signal for a developer who forgot to run migrate after the first deploy. The README now says so in the Deploy section; the skill cannot detect it.

The purge baseline is a file in git. migrate uploads every value to KV on every run, but it purges only the namespaces whose content changed since the last successful purge, as recorded in .i18n-hashes.json. If CLOUDFLARE_ZONE_ID or the token is missing, the purge is skipped with a warning and the file stays untouched, so the next run tries the same namespaces again. On a bare workers.dev URL there is no zone to purge at all, and cached namespaces stay stale for a year unless I18N_CACHE is off. The skill explains this; it cannot run migrate for you, and it should not.

A plugin update needs a new session. The agent that installed 0.1.3 keeps reading the 0.1.3 skill until the session restarts, even after claude plugin update says it has 0.1.4. The agent that rebuilt the demo noticed that itself and read the new forms.md from the plugin cache. A less careful one would have followed the old recipe.

The skill is prose, and prose drifts. Four of the six fixes in 0.1.4 were sentences in forms.md that were wrong: a missing option, a claim that the client regex matched zod byte for byte, a helper that printed raw codes. The package’s tests cover the runtime. Nothing but a consumer tests the instructions. That is the trade I made by putting the install procedure in a skill instead of a CLI wizard, and I would make it again, because the wizard would have had the same bugs with no agent to report them.

The branch rules live in a file the agent must read. main on the demo repo is protected and merges only through pull requests; react-islands is rebased and force-pushed; the archives are frozen. All of that is in AGENTS.md and in a private maintainer file. An agent that skips the file will try to merge the React branch into main, and the protection rule is the only thing that stops it.

Where this leaves the series and the starter kits

Parts 1 to 3 are still correct, and their code is still reachable: v1-version-based-cache and v2-purging-cache are branches of astro-edgekits-core, not tags, so every link in those articles keeps resolving. What changed is where new work goes. The engine evolves in @edgekits/astro-i18n, the site tracks every release the day it is published, and the demo repo is rebuilt by the skill rather than patched by hand.

For the starter kits, this part is the first proof of the format I want them in. A kit is a package and a skill, not a zip of files with a README. The package carries the mechanics and the tests; the skill carries the order of operations and the refusals.

An engine block wired to a circuit board as the agent-ready starter kit: a package with the mechanics and an agent skill as the operator

The repo you clone carries AGENTS.md, a plugin declaration and a Deploy button that provisions what the config declares. The agent does the copying, and the agent is the one who tells me when the instructions are wrong.

The next piece in this series is the tutorial version of this one: the exact prompt I gave an agent to build a multilingual Astro site on Cloudflare from an empty folder, what it asked back, and what it produced, with nothing edited for the camera. If you want to know when it lands, and when the first kits do, the Early Birds list is where I announce both. It is also the form this article is about: it works with JavaScript off now.

Leave your email to get launch discount • No spam ever

Links, for the record: the package on npm, its repository with the skill, the starter built with it, and the marketplace that installs it. If you build something on this, I want to hear about it, and if the skill gets something wrong on your project, that is a bug report I will act on the same day. The table above says so.

Frequently Asked Questions

What is an agent skill, and how does one install an npm package?

An agent skill is a folder with a SKILL.md file that tells a coding agent when it applies and what to do, plus reference files it reads on demand. The astro-i18n skill ships as a Claude Code plugin in the EdgeKits marketplace. When a developer asks for i18n, the skill asks six questions first (locales and default, markdown locale, geo fallback, middleware mode, flags), then runs npm i @edgekits/astro-i18n, writes edgekits-i18n.config.ts, adds the integration, moves the pages under src/pages/[lang]/, seeds the local KV and checks the result against a checklist.

How do I add skills to Claude Code from a third-party marketplace?

Two commands in Claude Code: claude plugin marketplace add EdgeKits/skills registers the marketplace, and claude plugin install astro-i18n@edgekits installs the plugin. With --scope project the install is recorded in .claude/settings.json, so the repository itself declares the skill and a fresh clone needs one install command to get it. A plugin update needs a new session before the new skill text loads.

What does the @edgekits/astro-i18n package contain, and what stays in my project?

The package holds the locale-routing middleware, the KV fetcher with its per-namespace edge cache, the edgekits-i18n CLI (bundle, seed, migrate with Purge API invalidation), the typed-schema generator, and two headless components: LanguageSwitcher and MissingTranslationBanner. The project keeps its own layouts, forms, SEO code (hreflang, sitemap, RSS, llms.txt) and the generated files the CLI writes. The skill writes that project code; the package does not ship it.

Why did the Astro action return 415 Unsupported Media Type for a form post?

An Astro action without accept: form only accepts JSON. A native HTML form post sends FormData, so when JavaScript is off the browser gets 415 UNSUPPORTED_MEDIA_TYPE with the message "This action only accepts JSON". With JavaScript on, the actions client sends JSON and the problem is invisible. The fix is accept: form on the action and new FormData(form) on the enhanced submit; the skill prescribes both since 0.1.4.

Does an AGENTS.md file work with Claude Code?

Yes. Claude Code reads AGENTS.md as the project instructions when no CLAUDE.md is present. A repository that also keeps a private CLAUDE.local.md needs a CLAUDE.md that imports AGENTS.md with a single @AGENTS.md line, because a local file alone switches the agent to CLAUDE.md mode. The demo repo ships AGENTS.md, a one-line CLAUDE.md and the enabled-plugin declaration in .claude/settings.json.

Can the Deploy to Cloudflare button provision a KV namespace for an Astro starter?

Yes. The button reads wrangler.jsonc from the source repository, creates the resources it declares (KV, D1, R2 and others), writes the new ids into the copy it makes in your account and deploys through Workers Builds. The source repository must carry placeholder ids. The i18n starter boots on its compiled-in fallback dictionaries until you run edgekits-i18n migrate against the new namespace.

How does the edge-native i18n engine behave when Workers KV is empty or unreachable?

It renders the default locale from fallback dictionaries compiled into the Worker at build time, for every locale, and it does so silently: the missing-translation banner is driven by a build-time check of which locales have JSON files, not by what KV holds. That makes an empty KV safe for a first deploy and invisible if you forget to migrate, so the first production step after deploying is edgekits-i18n migrate.

What is the difference between the main and react-islands branches of astro-edgekits-core?

main is the zero-JS starter: no UI framework, the newsletter form is a plain Astro form over an action with a vanilla progressive-enhancement controller. react-islands is main plus one commit that adds @astrojs/react and replaces that form with a react-hook-form island. The branch is rebased on main after every change, never merged, so the diff stays twelve files: the integration, the island, its dependencies and the docs.

Where did the previous architectures from Parts 1 to 3 go?

They are archived as branches in the same repository: v1-version-based-cache holds the content-hash architecture of Parts 1 and 2, v2-purging-cache holds the Purge API architecture of Part 3. Both are frozen. main now consumes the npm package, which carries the Part 3 engine forward.

October 15, 2026
← Back to Overview

We use cookies to analyze site traffic and improve your experience. By clicking "Accept All", you consent to our use of analytical tracking.