Post
AI in Your Content, at Build Time — Not in the Browser
There are two places you can put an AI call on a website: in the visitor's browser, or in your build. Almost everyone reaches for the first — a widget that calls an API on page load — and almost everyone regrets it. It ships your API key to the client (or forces a proxy you now maintain), it costs a request per visitor, it renders a spinner where content should be, and search engines see an empty box.
The build is the better place, and a static generator is exactly the tool for it. The AI runs once, when you publish. The answer becomes plain HTML in the file. Every visitor gets the same finished text, instantly, with no key, no spinner and no runtime at all. SSG 1.8.16 makes that a one-liner in your content.
The [ai …] shortcode
You declare a model once, in config, with the key in an environment variable:
1ai:
2 default_model: fast
3 cache_dir: .ai-cache
4 models:
5 fast:
6 url: https://api.openai.com/v1/chat/completions
7 key: $OPENAI_KEY
8 model: gpt-4o-mini
9 system: "Answer in one short, factual sentence."
A model is just the endpoint — where to reach the provider. When you want a
reusable role — a persona with rules it must follow and skills it's set up for —
you define an agent on top of a model and call it with agent="…". The
how-to walks through that; here a bare model is
enough. Then you ask, from inside the Markdown:
1## TL;DR
2
3[ai question="Summarise this release in one sentence: parallel rendering,
4 AI content, related posts, notifications." fallback="_summary pending_"]
At build time SSG sends the question to the model, drops the answer in where the shortcode was, and moves on. The reader sees a sentence, not a shortcode.
The part that makes it usable: it's cached and deterministic
An AI that answers differently every time would be a disaster for a static site — your pages would churn on every build, your diffs would be noise, and CI could never reproduce a build. So answers are content-addressed cached: the key is a hash of the model plus the exact question. Ask the same thing again and you get the same cached answer without touching the network.
graph LR
A["[ai question=…]"] --> B{cached?}
B -->|yes| C[use the stored answer]
B -->|no| D[ask the model once]
D --> E[write answer to cache]
E --> C
C --> F[plain HTML in the page]
Commit the cache_dir and the payoff compounds: your CI rebuilds the exact same
content with no API key and no network at all — the answers are already in the
repo, versioned next to the words a human wrote. A rebuild after a typo fix costs
nothing. You re-query only when you change the question or the model.
Ask only when it makes sense: the ifs guard
You rarely want every page hitting the API. ifs gates a query on the page's own
fields, with plain AND / OR and comparisons:
1[ai question="Write a one-line meta description for this article."
2 ifs="type == post AND lang == en AND status == publish"
3 fallback=""]
When the guard is false — a draft, a translation you handle by hand, the wrong
section — the fallback text is used and no request is made. Same when the query
fails or times out: you get the fallback, never a broken build. The API key is
read from the environment, so it never lands in the file or the output.
What it's good for
The honest use cases are the small, repetitive, per-page chores that are beneath a human but above a template: a one-line summary, a meta description, a "key takeaway" box, a plain-language gloss of a technical paragraph, alt-text drafts. Things you'd want consistent across hundreds of posts and wouldn't mind regenerating when the source changes.
What it is not for is the body of your writing. The generator will happily bake whatever you ask for into the page — the judgement about what belongs there stays yours. Build-time AI is a power tool for the margins of your content, run once, cached, and shipped as ordinary HTML that every reader and every crawler sees without executing a thing.