← Home
Yung Foundation · admin
01The deck · September 2026

Drafts, not decisions

The Yung Foundation’s admin is bilingual: every title, summary and caption is written twice, Marathi first. In September 2026 it gained AI help — translation both ways, writing help, photo descriptions, gallery and news suggestions from the photo library — plus a real body editor, WhatsApp sharing, map links and a live preview. One rule sits under all of it: the AI drafts, an editor decides.

Astro 7 · Supabase · Cloudflare Workers Gemini · Anthropic · AgentRouter 6 deploys + 2 follow-ups · migrations 0014–0017
the AI drafts… …a person saves मसुदा draft for review Save
02The problem

Every field, twice

The site speaks Marathi first, to readers in the Satpuda hills around Dhadgaon and Shirpur, and English second. Almost every text field in the admin is a मराठी / English pair, and a small team fills them in after field work, often from a phone.

23
Admin tabs
149
Library files
2
Languages a field
0 B
Public JavaScript

What piled up: English halves left empty, photos with no alt text (a screen reader gets nothing), newspaper cuttings on someone’s phone waiting to become news items, and a body editor that could not put a photo between two paragraphs.

every field, twice मराठी English alt text: —
03The rule

The AI drafts. An editor decides.

A foundation’s site makes claims about real people and real numbers, and donors rely on them. So no AI result reaches the public site on its own. Five rules, each enforced in code rather than left to good behaviour:

1

It lands unsaved

Every result goes into the form, not the database, with one level of Undo. A reload throws it away.

2

Bulk means a review list

Fifty photo descriptions or twelve clippings come back as a list with ticks. Nothing is written until it is ticked and saved.

3

It creates drafts only

Suggested categories, albums, gallery photos and news items are created unpublished.

4

It must not invent

The prompt forbids new facts, figures, names, dates, places and quotes. Code checks the ones a model writes anyway.

5

Some words stay human

Quotes and figures are translate-only. Public inbox messages never reach a provider.

AI drafts→ unsaved, in the form→ editor reads and fixes→ Save→ still hidden until published
04The map

Fourteen tools, five jobs

Ten use a model, four don’t. They all shipped together because each one leans on another: the gallery suggestions read the photo descriptions, the editor’s photos need the preview, and translation has to keep the editor’s photos in place.

Writing

  1. Translate both ways
    AI
  2. Write, expand, shorten
    AI
  3. Fill empty fields
    AI

Photos

  1. Describe a photo
    AI
  2. Describe fifty at once
    AI
  3. Several sections a photo
    no AI

Gallery & news

  1. Gallery from the library
    AI
  2. Read a news clipping
    AI
  3. News from the library
    AI

Publishing

  1. Editor with photos
    no AI
  2. Share on WhatsApp
    AI text
  3. Google Maps link
    no AI
  4. Appears on + live preview
    no AI

Control

  1. The AI tab: model, fallback, prices, usage
    AI
05WritingAI

Translation, both directions

Under every bilingual field: Draft English once the Marathi half has text, Draft Marathi once the English half does. Rich bodies translate as HTML and keep their photos where they were.

Where it gets used

  • A programme written up in Marathi after a camp: the English half in one click, then corrected.
  • A board member’s English note for the About page: a Marathi draft to fix, not a blank box to fill.
  • Alt text and captions in the photo library: the same two buttons.

One Undo puts back the value from before the last AI change. Nothing is saved until the editor presses Save.

MARATHI ENGLISH वार्षिक युवा संवाद व नेतृत्व शिबिर. An annual youth dialogue and leadership camp. AI draft one click, then read it Undo puts the old words back Draft English Draft Marathi Undo
06WritingAI

Write, expand, shorten, and every number checked

“AI writing help” opens under each field the AI may write: Write, Expand, Shorten, and an optional one-line note (“mention the Dhadgaon school”). The rest of the row is the context — only the tab’s own fields, read from the registry, so nothing else in the request can reach a provider.

  • A summary too long for a home-page card: Shorten.
  • A one-line event description: Expand, from what the body already says.
  • An empty WhatsApp message for a post: Write.

The first real run: asked about a programme running “since 2019”, a Flash-Lite model wrote २०१५ in the Marathi and 2019 in the English. Now every draft is compared with its source, and a number that isn’t in the source is listed under the field: “check them before saving: 2015”. Devanagari digits become Latin digits, in code.

checked against the source given since 2019 draft २०१५ → 2015 digits made Latin, in code not in the source — check before saving
07WritingAI

Fill the empty fields in one call

A new programme with only a title: Fill empty fields with AI drafts the summary, body and region in both languages from what the row already says. That is the “generate programme text” feature, and it works the same on blog posts, events and values.

  • No text, no draft. A row with nothing in it is refused — “write a title first” — because a draft from nothing is invention.
  • Only what is still empty. The reply lands in fields that are empty when it arrives, so anything typed while it ran wins.
  • Undo is exact. It lists what it filled, and Undo restores exactly those fields.
one call · empty fields only nothing written yet? “write a title first” title yours — kept वाचनालय व NEET summary body region summary body region Undo restores exactly what it filled
08Writing

Where the AI may only translate

Some words have to be the foundation’s own, or somebody else’s exactly. Five fields carry aiWrite: false: they show the translate buttons and nothing else, and the endpoint re-checks the registry rather than trusting the browser.

FieldWhy
testimonials.quoteA beneficiary’s own words. An invented quote is a consent problem.
key_facts.value
impact_stats.label
Figures donors rely on.
news_items.title
news_items.outlet
Somebody else’s headline and masthead.

The Page text tab is translate-only too. The Volunteer and Contact inboxes, the photo tab and the AI tab are refused outright, so a message from the public never reaches a provider.

“ a beneficiary’s words 123 a figure someone else’s headline translate only
09PhotosAI

A photo, described in two languages

Describe with AI in the photo library’s edit dialog drafts alt text (125 characters at most) and a caption in Marathi and English, and suggests up to three sections — Gallery, Program, Event and so on — added to whatever is already ticked.

  • Describe only what is visible. Never identify anyone, or guess age, caste, tribe or religion.
  • Never guess a place, date or event unless it is written in the picture.
  • Code drops a caption that repeats the alt text, and Marathi that slips into another Indic script — Flash-Lite once wrote शिलीमুক্ত, half of it Bengali.
  • The server loads the image by media id, under the editor’s own session; the browser never supplies a URL. JPEG, PNG or WebP, 1.5 MB at most.
only what is visible alt text मराठी / English caption · both languages sections, up to three gallery program event
10PhotosAI

Fifty photos, one at a time

Describe photos missing alt text takes up to fifty images that have none, describes them seven seconds apart — the free tier’s per-minute limit — and shows a review list with a counter and Stop.

  • A 429 waits thirty seconds and retries once. A photo it cannot read is skipped, not fatal.
  • Save ticked fills only what was empty; sections only where none are ticked.

A photo can belong to several sections

A health-camp photo can be Gallery, Program and Event at once. The library’s single “Context” dropdown became a row of checkboxes over media.contexts text[], GIN-indexed for the filter’s @>. The old context column stays until the deployed code stops writing it; a trigger bridges the gap. no AI

one by one, then a list to tick 7 s apart 429? wait 30 s, retry once 12 / 50 Stop review list up to 50 a run · nothing saved until ticked
11GalleryAI

A gallery arranged from the library

Suggest from library with AI on the three gallery tabs proposes a category and album for each photo not yet in the gallery, and new categories and albums where the photos need them.

  • Descriptions, not images. Each photo is described once; a suggestion then costs a few hundred tokens. Undescribed photos are counted and left out, with a pointer to the bulk describe.
  • Code keeps it sane: a proposed album needs two photos and a category three, or they are dropped; a photo stays in its album’s category (the (album_id, category) key); slugs must match the CHECK.
  • Drafts, in foreign-key order. Ticking a photo ticks the album and category it needs; everything is created unpublished, categories first, then albums, then photos.
album ≥ 2 photos · category ≥ 3 created as drafts: categories, albums, photos descriptions, not images album album category album category
12NewsAI

Reading a newspaper clipping

In News & media, Read a news clipping with AI reads the photo chosen as the item’s image — a cutting, or a screenshot of an article — and fills the fields that are still empty.

  • Headline and outlet, as printed and translated.
  • A two-to-three sentence summary in its own words, and the coverage type.
  • The date only if one is printed, and never in the future. A photo that is not news comes back empty.

Real run, 29 September: two Marathi cuttings came back with headline, outlet (Divya Marathi, Sakal), a printed date and summaries in both languages; the third had no printed date and came back without one. One summary named “Prabodhan Foundation”. Misread or not, catching that is what the review is for.

वृत्तपत्र date? headline — as printed and translated outlet summary, in its own words type: print / online / TV date: only if printed
13NewsAI

News from the library, without duplicates

Pull news from the library with AI reads photos one at a time — the News-section photos by default, or the whole library at one call a photo — and proposes a draft item for each real clipping.

  • Skips what is already there: a photo that is already some news item’s image, drafts included, is never read.
  • Drops what is not news, and says how many.
  • Flags a repeat: a headline matching an existing item letter for letter is marked “looks already added” and left unticked — the same article, scanned twice.
  • Won’t guess a date: a row without a printed one can’t be ticked until a date is typed.
one photo at a time, four questions already used? news? same headline? date printed? skipped not news flag it, leave unticked no? type one drafts 12 News photos
14Publishingno AI

Photos between paragraphs

Programme and blog bodies moved from a three-button contenteditable to Tiptap (ProseMirror): headings, lists, quotes, dividers, links, and library photos placed between paragraphs with a caption. Not an AI feature — but AI translation has to keep its photos where they are.

<figure data-media-id="143">
  <figcaption>…</figcaption>
</figure>
  • An id, never a path. The public page resolves it at render time; a figure whose photo is a draft or deleted disappears instead of rendering broken.
  • The schema is the paste filter: pasted images, scripts, tables and javascript: links are dropped, the text kept.
  • 404,536 bytes (125,972 gzipped), loaded only when a rich field opens. Public pages still ship 0 bytes of JavaScript.
<figure data-media-id="143"> an id, never a path draft photo? the figure quietly disappears B I H2 “ — photo caption
15PublishingAI text

Share on WhatsApp, with the right words

In the Satpuda, a post travels by forward, not by feed. Every post now has a Share on WhatsApp button: a plain wa.me link, no script.

  • The message is the post’s new WhatsApp message field (blog_posts.share_text) — bilingual, and draftable by the AI like any other field — or else the title and excerpt. The post’s link is appended, in the reader’s language.
  • og:image comes from a post’s or programme’s cover, with its size and alt text, plus summary_large_image. That is what puts a picture in the chat preview.
a wa.me link, no script og:image = the cover the message in the reader’s language …/blog/…?lang=mr
16Publishingno AI

A map link for every event

The Events tab takes the address Google Maps’ Share button gives. The events page shows Open in Google Maps ↗ under the location: a 44 px link that opens a new tab.

  • A link, not an embed. An embedded map would load Google on every visit and join the privacy notice’s list of providers.
  • The database checks the host. Only https Google Maps addresses get in: maps.app.goo.gl, goo.gl/maps, google.com and google.co.in /maps, maps.google.*. Plain http, a look-alike host, a search URL or javascript: comes back as events_map_url_check.
a link, not an embed no Google on every page view Open in Google Maps ↗ maps.app.goo.gl/… http://maps… javascript: goog1e.com/maps events_map_url_check
17Publishingno AI

See where the words land, as you type

Every content tab now says where its rows appear: “Appears on: /programs/youth-dialogue-camp — its own page · /programs — the list · / — home page cards”. See it on the site opens that real page in a frame, with unsaved text pushed in as you type and the edited row outlined, in Marathi or English.

  • ?preview=1 works for signed-in writers only — the middleware asks is_writer(). A visitor who adds it gets the ordinary page: no markers, no script.
  • Preview responses are private, no-store and noindex, and skip the analytics beacon.
  • Fields are found by their saved text inside the row, so only eleven row containers carry a marker.
  • Switching the frame’s language doesn’t switch the admin’s: in preview, ?lang= renders without writing the cookie.
the real page, not a copy Appears on: /programs/… · / title Youth Dialogue Camp summary See it on the site /programs?preview=1 Youth Dialogue Camp postMessage, as you type writers only · visitors get the ordinary page
18ControlAI

One model, one fallback, every call logged

The AI tab picks the provider and model for every feature, plus an optional fallback tried once on 402, 429, 5xx, no answer or an empty answer. A refused key or a misspelt model is not retried: a second provider answering would hide the misconfiguration. Test calls what is typed, with no fallback.

130
Calls, Sept
58
Saved by fallback
67k
Tokens in
$0.04
Estimated

One ai_usage row per attempt: who, task, tab, provider, model, tokens, a short failure code, the fallback flag. Cost is snapshotted at call time from the prices typed in settings, so a price edit never reprices history; the key runs on Gemini’s free tier. The monthly view is security_invoker, and only admins can read it.

September 2026: calls by model

The active model failed 59 of its 60 calls, nearly all rate limits (429). The fallback answered 58 times and carried the month.

gemini-3.8-flash (active): 60 calls, 1 answered, 59 failed gemini-3.8-flash · active 1 answered · 59 failed gemini-3.5-flash-lite (fallback): 59 calls, 58 answered, 1 failed gemini-3.5-flash-lite · fallback 58 answered · 1 failed gemini-3.5-flash: 10 calls, 2 answered, 8 failed gemini-3.5-flash 2 answered · 8 failed AgentRouter, gemini-3.5-flash: 1 call, failed (401) AgentRouter · gemini-3.5-flash 1 failed · 401
Show as a table
ModelCallsAnsweredFailed
gemini-3.8-flash (active)60159
gemini-3.5-flash-lite (fallback)59581
gemini-3.5-flash1028
AgentRouter · gemini-3.5-flash101

Totals: 130 calls, 69 failed attempts, 58 answered by the fallback; 67,330 tokens in, 7,805 out. By task: describe 76, translate 38, writing help 6, clipping 5, gallery 3, test 2.

19Under the hood

One endpoint, three providers, one switch

Every AI feature posts to /api/admin/ai. It checks in a fixed order, runs the task on the active model, falls back once if it may, and logs every attempt. The provider layer is one plain switch over three fetch branches, with no SDK.

GeminiAnthropicAgentRouter
Authx-goog-api-keyx-api-keyBearer
Call:generateContent/v1/messages/chat/completions
JSONresponse MIME typeprompt + loose parseprompt + loose parse
ImageinlineDatabase64 blockdata URI

Upstream error bodies never reach the browser: an editor sees a short code and the provider’s name. Gemini’s thinking tokens count as output, because they are billed as output.

POST /api/admin/ai 501 · no key set 415 · not JSON 401 · signed out 403 · not a writer 400 · bad input 503 · no model in this order · fails closed Gemini x-goog-api-key Anthropic x-api-key AgentRouter Bearer · OpenAI-style on 402 · 429 · 5xx · empty: try the fallback, once ai_usage: a row per attempt
20Under the hood

Prompts ask. Code checks.

Every rule below is written in the prompt, and a real run broke every one of them before code enforced it. /api/health runs a fixture through each.

Latin digits

Devanagari digits become Latin in every task.

२०१५ → 2015

Unverified numbers

A number not in the source is listed under the field.

check before saving: 2015

One script

Marathi with another Indic script in it is dropped, so the field reads “needs translation”.

शिलीमুক্ত

Caption ≠ alt

A caption that repeats the alt text is dropped.

Real groups

An album needs 2 photos and a category 3; a photo keeps its album’s category.

Dates

A clipping’s date only if printed, and never in the future.

HTML allowlist

AI output ends up in set:html on public pages, so sanitizeRich keeps only p h2 h3 ul ol li blockquote strong em, a with https or mailto, and the editor’s figure.

<img onerror> → gone
prompts ask, code checks /api/health runs a fixture through each model output २०१५ <script> caption = alt album · 1 a Bengali half 2015 draft
21Under the hood

Keys, people, and a gateway that said no

server browser secrets GEMINI_API_KEY ANTHROPIC_API_KEY AGENTROUTER_API_KEY no key in here Draft English 403 · not a writer free tier: the owner’s call, 29 Sep AgentRouter: 401, and no disguise

Keys stay in Cloudflare

GEMINI_API_KEY, ANTHROPIC_API_KEY and AGENTROUTER_API_KEY are runtime secrets read through astro:env/server. The AgentRouter base URL is an environment variable too, not a setting: an admin-editable URL would let any admin send the key somewhere else. CI greps dist/ for AIza…, sk-… and AQ.… key shapes.

Writers only, and it fails closed

The old translate endpoint checked sign-in but not role, so a viewer could spend the key. The new one asks is_writer(), and an error there is a refusal.

Free tier, eyes open

On Gemini’s free tier, Google may use the text and photos sent to improve its products, and people may review them. The photos show beneficiaries, children among them. The owner accepted this on 29 September 2026; consent wording is flagged to the foundation, and the AI tab says it in plain words.

AgentRouter said no

It serves only the coding tools it recognises and answered 401 unauthorized client. The site calls it with an honest user-agent: YungFoundationCMS/1.0 and won’t pretend to be anything else; the tab says so, and the fallback covers it.

22What broke

What broke on the way

  • The recommended model was gone. gemini-2.5-flash answered 404 for a new key. The newer Flash models were overloaded (503), then rate-limited: 59 of 60 calls failed with 429.
  • Flash-Lite’s first drafts had Devanagari digits, a wrong year, Bengali letters in Marathi, captions identical to the alt text and a one-photo album. Each one is now a code check.
  • The Cloudflare build refused the lockfile. npm 11 wrote it and npm ci on npm 10 rejected it. Lockfiles are now written with npm 10.9.2, the build’s own npm, and dry-run before pushing.
  • “Saved” that wasn’t. A row-level-security refusal on update returns 0 rows and no error, so an editor saving AI settings saw “Saved”. The save now reads the id back and treats 0 rows as “admins only”.
  • The old rich-text field ignored AI. It read its value once on mount, so a translated body never showed, and the next blur wrote the old HTML back. Tiptap replaced it.
404 · model retired 503 · overloaded 429 on 59 of 60 a wrong year: 2015 half-Bengali Marathi caption = alt text npm ci · lockfile 0 rows → “Saved”
23What’s next

What’s next

  • Swap the models. Make Flash-Lite the active model, or move to the tier the current one needs; the usage table already says which.
  • Finish the sections migration. Drop media.context, its index and the bridge trigger once the new code is deployed.
  • Consent wording for photos sent to a free-tier model, from the foundation.
  • Watch Workers CPU on the first real bulk runs, since images are base64-encoded on the Worker.
  • The accessibility pass (WCAG 2.1 AA), still the thing between this site and launch.
The whole site, end to end → Every figure here is from the repo’s changelog and the live usage table, 30 September 2026.
for review Flash-Lite first drop media.context consent wording