How I built this blog: a coding agent that argues back
I asked an AI agent for a blog page, and got interrogated instead. Here is what that produced: Teable as the data layer, and one bug caught before it shipped.
- pi
- teable
- nextjs
I wanted a blog page. What I got first was an interrogation, and the page was better for it.
The agent interviewed me before it wrote anything
I built this with pi, a coding agent, running a "grilling" skill. It asks one question at a time, and recommends an answer.
Here are some of the useful arguments I came across with: Where does the content live? Should drafts exist? What happens to an unfinished post? I answered maybe a dozen of these.
Teable as the data layer
Posts are not files in this repo. They are records in a Teable base called
annemariel.com, in a table called Blog — title, slug, status, excerpt,
content, cover image, tags, category, SEO fields, and reading time.
The appeal is that publishing is not a deploy. I flip a record's status from
Draft to Published, and the site picks it up. Next.js revalidates hourly,
so pages stay static and CDN-fast while the content stays editable from a
spreadsheet-shaped UI.
The whole integration is one module. Nothing else in the codebase knows Teable exists:
export async function getPostBySlug(slug: string): Promise<Post | null> {
return (await getPosts()).find((post) => post.slug === slug) ?? null;
}Status handling has one nice property: in development every post is visible,
including drafts and in-review pieces. In production, only Published. Writing
in progress is always one environment away from being seen, and never one
mistake away.
The bug that would have shipped
Here is the part I'm glad I caught before deploying.
Teable's attachment fields hand you a presignedUrl for each image. It works
perfectly until it expires. Bake that URL into a static page and your cover
images are fine on launch day and broken a few days later. Worse for Open
Graph: social platforms cache the image URL they scrape, so a dead link
propagates into every share of the post.
The fix is to never let the temporary URL reach the browser. Each cover is served from a stable path on my own domain, and a small route handler resolves a fresh presigned URL server-side and streams the bytes back:
const source = await getCoverSource(params.slug);
const upstream = await fetch(source);
return new Response(upstream.body, {
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/png',
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
},
});The expiring URL stays server-side. The public URL never changes. The CDN caches the bytes, so this is not a function call per visitor.
That is the real output of an hour of being grilled: a shorter list of things I actually built, and a much clearer idea of why each one is on it.