A one-language blog, three days later in three
A while ago we had an Astro website in a single language. Last week, the same blog was serving Spanish, Catalan and English, with a language selector, per-locale RSS feeds and sitemap, and a deployment that ran itself from Git.
It wasn’t magic. It was an agentic team: a set of Hermes agents working with GitOps (Flux CD), a local model (Qwen 3.8 27B) and a tool like OpenCode, on top of an infrastructure base we already had in place. What I want to tell you is not the feat: it’s how we did it, step by step, with enough detail for you to replicate it. And what we learned about cost along the way.
This post tells a real case from the ia_develop/astro repository. The work was done on the develop branch, but today the blog lives on the main branch (tag 0.0.2) with full i18n, in production. It is not a fiction: every number here comes from the git history, the Kanban state or the logs. If something is not real, it is not here.
The agentic team: who did what
The first thing to understand is that there wasn’t “one person working 60 hours”. There were several agents working in odd hours, coordinated by a Kanban board that was the source of truth. The i18n migration work was split like this (18 commits, from August 19 to 25, on the develop branch, later merged to main with tag 0.0.2):
| Who | Type of work | Real example (commit) |
|---|---|---|
| Hermes (main agent) | i18n framework, content localization, routing, feeds | feat(i18n): content layer per-locale (es/ca/en), feat(i18n): per-locale RSS + sitemap feeds |
| OpenCode | UI and improvements with direct operator support via livecoding | i18n: extract UI strings to locale files (nav, about, index) |
| Humans (operators) | Review, decisions, content | feat(i18n): localize blog index per-locale, i18n(content): add ca + en versions… |
The point is not that the agents wrote alone. It’s that the work was split into small, verifiable tasks, each with a checkable outcome. Kanban was the board; Git was the track; human review was the quality control.
This is important: an agent doesn’t “solve” the problem alone. An agent does a task, leaves it in a verifiable state, and the next one picks it up. The coordination is not in an agent’s head, it’s in the board.

Evidence: the Hermes board (Kanban) that served as the source of truth during the i18n migration.
How we did it, step by step
The flow had five phases. Each one is a Kanban task with its own outcome and its own review.
1. Investigate before writing
You don’t plan without data. Before touching anything, the agent asked: what’s already there, what’s in the cluster, what did each one do? The following were inventoried:
- The Kanban boards (what work existed:
astro-blog-i18n,astro-ai-blog-delivery, etc.). - The GitOps flow (how the image is built and deployed).
- The documentation (
docs/, ADRs, runbooks). - The commits by author (what Hermes vs. OpenCode vs. the human did).
Without that evidence, the post would be a fiction. With it, it’s a truthful account.

Evidence: so many additions for so little time… a madness (in a good way).
2. Write the content (with review)
The post is written in the voice we read every day, but signed by whoever understands the technique. Here we apply a non-negotiable rule: every task that generates content, code or manifests ends with a review. The draft wasn’t published without passing through a human eye.
3. Verify the build
npm run build && npm run preview
# and check that the post appears in the three languages
grep -c '<slug>' dist/rss.xml dist/sitemap.xml # >= 1 per language
A green build and the correct feeds in the three languages close this phase.
4. Open the PR (never directly to main)
Never commit directly to main. A feature branch is created and a pull request is opened. Here the agent’s work ends: the human decides whether to merge.
5. Merge + deployment (human)
The operator merges, Flux reconciles, the Image Update Automation consumes the branch and deploys. The agent does not merge or deploy to production. That decision is human, always.
The rule that holds everything together: all work goes through Kanban, and every task that generates code, manifests or scripts ends with a required review.
Reproducible technical detail
Here comes what you can copy. All the code that follows is real and is on the main branch (tag 0.0.2) of ia_develop/astro.
a) Configure the languages (src/config.ts)
The languages are defined once, in a single place:
export const locales = ['es', 'ca', 'en'] as const;
export const defaultLocale = 'es';
export type Locale = (typeof locales)[number];
// Proper (standalone) names of each language, in a single place
export const localeNames: Record<Locale, string> = {
es: 'Español',
ca: 'Català',
en: 'English',
};
The idea is that navigation labels are not “hard” in the code, but resolved per language. The navItems array holds key, and the label is looked up in the active language’s catalog.
b) Per-locale routes with Astro (src/pages/[locale]/index.astro)
Astro uses file-based routing. If you put a file in the [locale]/ folder, it generates a variant for each configured language:
export const getStaticPaths = () =>
locales.map((locale) => ({ params: { locale }, props: { locale } }));
const { locale } = Astro.props as { locale: (typeof locales)[number] };
const t = getMessages(locale);
A single index.astro inside [locale]/ produces /es, /ca and /en without duplicating the code.
c) Resolve the language and dates (src/lib/i18n.ts)
import { getLocaleByPath } from 'astro:i18n';
import { locales, defaultLocale, type Locale } from '../config';
import es from '../locales/es.json';
import ca from '../locales/ca.json';
import en from '../locales/en.json';
const messages: Record<Locale, typeof es> = { es, ca, en };
export const getMessages = (locale: string | undefined) => {
const key =
(locale && (locales as readonly string[]).includes(locale)
? (locale as Locale)
: defaultLocale) as Locale;
return messages[key];
};
The interface strings (navigation, about, index) are extracted to src/locales/*.json. The post content lives in src/content/blog/{es,ca,en}/<slug>.md.
d) Per-locale content without collisions (src/content.config.ts)
The trick that makes three languages of the same post not collide is the generateId:
const blog = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/blog',
generateId: ({ entry, data }) => {
// entry = "ca/getting-started-with-astro.md"
const rel = entry.replace(/\\/g, '/').replace(/^\.?\/+/, '');
const [folder, file = ''] = rel.split('/');
const slug = data?.slug || file.replace(/\.md$/i, '') || rel;
// unique id: <locale>-<slug>
return LOCALES.includes(folder) ? `${folder}-${slug}` : slug;
},
}),
schema: z.object({
title: z.string(),
slug: z.string().optional(),
locale: z.enum(LOCALES as [string, ...string[]]).default(defaultLocale),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()),
author: z.string().optional(),
image: z.string().optional(),
featured: z.boolean().optional(),
}),
});
Without this generateId, two locales of the same slug would collide in the store and one would overwrite the other. The id as <locale>-<slug> resolves the conflict.
e) Deployment with GitOps (the CI of the main branch)
The flow that turns a commit into a deployment is pure GitOps. The Forgejo CI emits an immutable chronological tag:
# .forgejo/workflows/build-image.yml (summary)
env:
REGISTRY: registry.cris-mora-cv.es
UNIXTS: $(date +%s) # immutable tag: 0.0.2
# develop -> :develop (preview), main -> :launch (production, tag 0.0.2)
The execution of these jobs is not done on a fixed machine: Forgejo triggers the workflow (git actions) in ephemeral pods controlled by KEDA. KEDA scales Forgejo’s workers from zero based on queue events, starts the pod only for the job and destroys it when done; so there is no idle CI infrastructure, only the right amount is consumed per deployment.

Evidence: the build flow in Forgejo Actions (git actions) running in ephemeral pods scaled by KEDA.
The Image Update Automation of Flux consumes that branch and a numerical policy:
ImagePolicy: pattern '^0.0.2-' + policy.numerical desc
-> the most recent build (the larger unixts) wins, and deploys itself.
A commit on main → Forgejo runs the job in an ephemeral pod (KEDA) → CI emits the image 0.0.2-<unixts> → IUA detects the new image → Flux reconciles → the blog updates. Without touching the cluster by hand.
Skills: how Hermes remembers what it learns
A part that’s sometimes overlooked: Hermes doesn’t reinvent the wheel on every task. It uses skills, procedures that are saved and reused. Some of the ones this work applied:
astro-blog— create and publish posts (content layer, feeds, build, deploy).flux-gitops-integration— audit Flux GitRepos and Kustomizations.flux-image-automation— debug when the IUA doesn’t reconcile.kanban-board-management/kanban-worker— the board as the source of truth.forgejo-git/forgejo-http-troubleshooting— Git operations with internal Forgejo.
The interesting part is that some skills are self-learned: when a flow is hard and gets solved, Hermes saves it as a skill so it doesn’t have to fight it again. It’s procedural memory: it doesn’t just remember what happened, but how it was done.
Numbers and reflection: the cost of AI is not what it was
Let’s get concrete, because this is what I really wanted to convey.
The i18n migration left 18 commits on the develop branch (merged to main, tag 0.0.2), from August 19 to 25 — a window of about 6 days. We had 7 posts in the three languages and a complete infrastructure (routes, feeds, switcher, routing). The work was done in odd hours, split among several agents and a couple of humans.
This doesn’t mean it cost nothing. It means the cost stopped being linear with person-hours. Before, taking a blog to three languages was a week of one person, and then another week to maintain it. Now it’s an activity of odd hours that a couple of agents coordinate, with a human reviewing at the end.
And here’s the reflection I want to leave you with: imagine what could be done with enterprise hardware — a server with real GPUs — versus the machine this was tested on, a DGX Spark with GB10. If with a desktop machine running a local model (Qwen 3.8 27B) we could do this in an agentic team, the quality jump with serious infrastructure is not incremental: it’s of another order.
The conclusion I take away is this: the bottleneck is no longer the hardware or the model’s price. It’s knowing how to ask well and coordinate well. With local resources, the cost of “an agentic team” approaches zero compared to what a full-time person dedicated to repetitive tasks used to cost. The future is not “more humans working more hours”; it’s “better coordination among agents, with humans at the points that matter”.
Closing
We went from one language to three. It wasn’t a miracle: it was an agentic team working with Kanban as the source of truth, GitOps as the highway, a local model as the brain and a human review at the decision point.
If you want to replicate it, the code you saw above is on ia_develop/astro (main branch, tag 0.0.2). If you want to copy the method, the rule is simple: split the work into small tasks, verify it, and leave the important decision for the human.
This post closes the blog’s arc with what was achieved: the i18n migration in a week. What comes next is the roadmap of how we set up our homelab as the development team — the infrastructure (cluster, GitOps, local models, Forgejo and KEDA) that made everything you saw here possible.
And if the question “what about real hardware?” is left hanging, the answer is what this post should make clearest: a desktop machine already does it; with enterprise hardware, what could be done today is hard to imagine.