ai · engineering-practice
Notes on agentic engineering: CLAUDE.md and agentic loops
A synthesis of Better Stack's published playbook for shipping production code with an AI agent, restructured as reference notes.
- Published
Better Stack's CTO and CEO published their own internal AI-engineering playbook, written for their own team. These are my notes on it, restructured for reference, not a copy: read the original for the direct account.
Vibe coding vs. agentic engineering
These are not two points on one quality spectrum. They are different tools for different jobs.
Vibe coding is fast, throwaway, and does not need to be secure or maintained. It is good for prototyping and validating an idea, and the output is disposable by design.
Agentic engineering produces code intended to reach production, held to the same bar as human-written code: tested, secure, documented, and maintained going forward.
The line is not how good the model is. It is what happens to the code after today. A working checklist for whether agent-written code is actually ready to ship, distinct from whether it merely runs:
- Tests exist and were run, not only generated alongside the code.
- The change was exercised end to end, not verified by reading the diff.
- Security-sensitive surfaces — auth, input handling, anything touching money or personal data — got a deliberate human read, not just the agent's own report that it looked fine.
- The reasoning behind any non-obvious decision is written somewhere durable: a code comment, a design doc, a commit message, not only a chat transcript no one else on the team can see.
CLAUDE.md as the real system prompt
The core claim worth internalizing: the real engineering work now happens in the CLAUDE.md file, not in the individual prompt. The example further down is the one this site is built with. A well-designed one means short, simple prompts produce good output by default. A weak or missing one means every prompt has to re-explain context the agent should already carry.
Three required parts.
A one-sentence description of the codebase, terse, with no explanation of things the model already knows. "This is a Ruby on Rails and PostgreSQL backend for an iOS application that handles subscription billing."
Rules — behavioral instructions, not product knowledge. This is meta-programming in plain English: what the agent should and should not do. Domain knowledge does not belong here, because it loads into every session regardless of relevance, which wastes context and can degrade output quality.
A routing table — where to find things: adjacent codebases, relevant docs, production telemetry sources. A markdown table saying "when looking for X, look in Y" is enough. The signal that this section is missing or thin: the agent's own output says something like "exploring the codebase to understand X." That is the agent doing work the routing table should have made unnecessary.
A few rules worth having, independent of stack:
- Test-driven development. A written test is implicit documentation of intended behavior, and it lets an agent validate its own change without a human in the loop for every step.
- A one-sentence doc comment on every class and method. Cheap, and it is additional grounding context the agent reads before touching nearby code — repetition over abstraction, for an agent-legible codebase.
- Document non-trivial cross-file coupling directly in the code, on both sides. An agent's practical tool for finding related code is grep. Anything only implied by git history or convention is invisible to it unless written down at the point of coupling.
- State naming and UI conventions explicitly. A consistent product is close to free once these are written down, because the agent applies them by default on every change.
- Point to the best existing example when asking for something new. Without that pointer, an agent has no signal for which part of the codebase to imitate, and may draw from mediocre code as easily as excellent code.
- Require end-to-end verification after implementing a feature — signing in and exercising it in a real browser, not trusting that code which compiles and passes unit tests actually works end to end.
- State the quality bar explicitly: response-time targets, dark-mode support, auth on every protected route. An unstated quality requirement does not get enforced by default. A stated one does.
Agentic loops
An agentic loop is an agent working for an extended period, checking its own work against stated, objective conditions, with minimal back-and-forth. Being vague about what "done" looks like is the actual failure mode — an agent cannot build toward a target that was never described concretely.
A structure worth borrowing for a backend feature: write the design document first and review it word by word before any code exists, because that step is cheap to redo and expensive to skip. Implement in a clean workspace, so review stays comparable to the design doc rather than an evolving diff. Have the agent enumerate what "fully working" means for the feature and turn that into a concrete verification plan, rather than leaving "working" undefined. Run the simplest possible smoke test before the full suite, because catching total breakage early is cheaper than discovering it after a long run. Run the remaining verification in parallel where the checks are independent, and review the diffs it produces rather than re-reading every line of implementation.
The throughline: an agentic loop is only as good as the quality criteria it checks itself against. Same idea as the CLAUDE.md quality-bar rule above, applied at the scale of a whole feature instead of a single file.
A candidate who can describe their own setup this specifically
The genuinely useful test, for anyone evaluating how they work with an agent, is whether they can describe their own CLAUDE.md, or Cursor rules, or equivalent, in this level of structural detail: description, rules, routing table, not "I have a prompt file." "I use AI to help me code faster" is a different, much shallower claim than being able to say what the rules actually are and why each one is there.
Example
<!-- BEGIN:nextjs-agent-rules -->
# Claude Instructions
A Next.js 13+ App Router portfolio site with MDX content, static generation, and the Datum design system.
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
## Codebase description
Single-page portfolio showcasing engineering work across hardware sensors, backend systems, frontend platforms, and full-stack projects. Entirely server-side: zero client components, no hydration, no island architecture. Content is prose, not code: `.mdx` files live at the repository root under `content/`, not in `src/`. All pages are statically generated at build time via `generateStaticParams` and `generateMetadata`. Every page loads content through a single loader (`src/lib/content/index.ts`) so ordering, metadata, and schema validation are centralized and cannot drift across pages.
## Rules
### Content and data
- **Content lives at the repository root.** `content/projects/`, `content/blog/`, `content/home.mdx`, etc. are data, not code. Editing an MDX file should feel like editing prose, not editing the application. Prose does not belong in `src/`.
- **One loader, one source of truth.** All content loads through `src/lib/content/index.ts`. No page imports `.mdx` files directly. No in-page fetch. If a third page needs the same data, the loader owns the order, schema, and deduplication.
- **Schema validation is a build-time hard stop.** Zod schemas in `src/lib/content/schema.ts` validate frontmatter. A malformed `projects/foo.mdx` blocks the build rather than silently dropping the entry or rendering a broken card. This is intentional: a portfolio that silently omits work is worse than one that refuses to compile.
- **Describe limits as explicitly as code.** If something is unfinished, unverified, or scaffolded-but-inactive, name it on the page. See `projects/[slug]/page.tsx` for the `verified` field and `VOICE.md` §2.4 for the tone. No feature should hide incompleteness behind future tense.
### Architecture and structure
- **No client components.** Everything in this site is a Server Component. If a future feature requires client interactivity, add it at the narrowest scope (one `"use client"` island, not the whole page) and track it in `REPO_LAYOUT.md` §4 before committing.
- **Content and UI are separate.** `src/components/content/` owns MDX-rendering concerns like `Mdx.tsx`. `src/components/layout/` owns site structure. `src/components/ui/` owns primitives. Do not put a typography preset in layout and a different one in content.
- **Site identity is centralized.** `src/lib/site.ts` owns the site URL, name, and social links. Every place that needs the canonical URL (layout metadata, OG images, JSON-LD, sitemap, footer) reads from this file, not from scattered constants.
- **Metadata is generated from schema.** `pageMetadata()` in `src/lib/metadata.ts` requires an explicit `path` argument to prevent a documented canonical-URL bug. Use it in every page that needs metadata, including those with dynamic `generateMetadata` functions.
### Page generation
- **Static generation is the only mode.** Every page calls `generateStaticParams` at build time. Routes are known and finite. No dynamic fallbacks, no ISR, no `revalidate`. If a new post ships, rebuild the entire site.
- **Ordering logic lives in the loader.** `getProjects()` sorts featured-first, then newest. `getBlogPosts()` does the same. Pages do not re-sort, do not apply filters, do not duplicate the rule. This prevents drift and keeps every consumer in agreement.
- **Featured content is a first-class flag.** Projects and blog posts can have `featured: true` in their frontmatter. The loader sorts them first. A page's index shows featured content prominently and in the same visual treatment as other content, not in a separate "Hero" section. The feature is there, not hidden behind scarcity.
### Voice and copy
See `VOICE.md` for the full ruleset. TL;DR:
- No contractions, anywhere.
- No exclamation points.
- Sentence case always. Never title case.
- Claims need checkable anchors: repo links, explicit mechanisms, or honest "not yet measured" labels.
- Limitations are stated plainly, no apology theater.
- Adjectives do not stand in for mechanisms: "robust" is not a claim unless you explain what makes it robust.
- First person (reflective pages like `/about`) sounds like you talking directly. Technical explanation (buttons, errors, project cards) sounds like a tool labeling itself.
Read everything aloud before shipping. If it sounds like a marketing brochure or portfolio template, it is not ready.
### Testing and build discipline
- **Every content file is built once.** If you add `content/projects/foo.mdx`, the build parses it once via `matter()`, validates it once via Zod, and caches the parsed result. Do not import the same file twice.
- **Type safety on the schema.** Adding a field to `projectSchema` or `blogPostSchema` requires updating all the type exports. Running `tsc --noEmit` catches mismatches before a page fails at runtime. Run it before pushing.
- **Verify page counts in PDF.** When exporting resumes or CVs to PDF for inclusion in `/cv`, render to PDF and assert 1 page. Five of the original ten "finished" resumes were silently 2 pages. A page-count bug is silent until someone opens it.
## Routing table
| Find | Look here | Why |
| ----------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Site identity (URL, name, email, socials) | `src/lib/site.ts` | Single source for metadata, OG, JSON-LD, footer. |
| Content schema (frontmatter shape) | `src/lib/content/schema.ts` | Every `.mdx` file is validated against this. Breaking it blocks the build. |
| Content loader (reading and sorting) | `src/lib/content/index.ts` | All content loads here. No page imports files directly. |
| Page metadata generation | `src/lib/metadata.ts` | Required `path` argument prevents canonical-URL bugs. |
| Site-wide prose rules | `VOICE.md` | How every sentence on the site should sound. |
| Design system tokens and classes | `design-tokens.css` and `DESIGN_SYSTEM.md` | Font sizes (`text-display`, `text-h2`, etc.), spacing, colors. All Tailwind. |
| Repo structure and conventions | `REPO_LAYOUT.md` | Why content lives at root, why components split three ways, client-component budget. |
| Project listings | `content/projects/*.mdx` | Source of truth. One `.mdx` per project. Frontmatter: title, summary, role, stack, repo, live, featured, date, verified. |
| Blog posts | `content/blog/*.mdx` | Source of truth. One `.mdx` per post. Frontmatter: title, summary, tags, date, updated (optional), featured (optional). |
| Home page copy | `content/home.mdx` | Schema in `schema.ts`: headline, intro, work label, CTA. No body content. |
| About page | `content/about.mdx` | Frontmatter: title, description, portrait (optional). MDX body rendered via `Mdx.tsx`. |
| CV page | `content/cv.mdx` | Frontmatter: name, title, location, work authorization, email, github, linkedin, site, languages, updated. MDX body is the actual CV markup. |
| Page-specific UI | `src/app/projects/page.tsx`, `src/app/blog/page.tsx` | List pages. Do not sort, do not filter, do not duplicate loader logic. Call `getProjects()` or `getBlogPosts()` and render. |
| Dynamic page generation | `src/app/projects/[slug]/page.tsx`, `src/app/blog/[slug]/page.tsx` | `generateStaticParams` from loader slugs. `generateMetadata` from loaded post. Content via `Mdx` component. |
| Sitemap and robots | `src/app/sitemap.ts`, `src/app/robots.ts` | Auto-generated from content loader. No hardcoded routes. |
| MDX rendering | `src/components/content/mdx.tsx` | Wraps `MDXRemote` from `next-mdx-remote/rsc`. No component map: styling lives in globals.css under `.prose-datum`. |
## Agent-specific notes
### When adding a new project
1. Create `content/projects/slug.mdx` with frontmatter: title (required), summary ≤200 chars (required), role (required), stack (required array), repo (optional URL), live (optional URL), featured (optional, defaults false), date (required), verified (optional, defaults true).
2. Zod validates it at build time. Malformed frontmatter fails the build with a specific error naming the file.
3. Do not edit pages to include it. `getProjects()` discovers the file automatically.
4. Run `tsc --noEmit` to catch type drift.
### When adding a new blog post
1. Create `content/blog/slug.mdx` with frontmatter: title, summary ≤200 chars, tags (required array), date, updated (optional), featured (optional, defaults false).
2. Zod validates. Featured posts sort first, same rule as projects.
3. Do not edit pages. `getBlogPosts()` discovers it automatically.
### When updating site identity
1. Edit `src/lib/site.ts`.
2. All metadata, OG images, JSON-LD, sitemap, and the footer pull from this single source.
3. Test the build. A stale URL will fail `pageMetadata()` validation if the domain no longer exists or resolves differently.
### When a build fails on content
Error message will name the file and the schema violation. Look at `src/lib/content/schema.ts` and fix the frontmatter, not the code.
### When adding client interactivity
Before writing `"use client"`, ask: can this be a Server Component that renders HTML + inline `<script>` instead? If yes, do that. If you must use a client component, add one row to `REPO_LAYOUT.md` §4 with its name and justification, then write the component as narrowly as possible. No global client wrappers.
<!-- END:nextjs-agent-rules -->