Codebase Design for AI Agents: Types, Lint Rules, and CI Gates

SitePoint TeamPublished inAI·Programming·JavaScript·
October 20, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
AI coding agents generate production code, shifting the developer’s role from writer to reviewer. That shift exposes a specific failure mode: the constraints a codebase enforces at compile time and CI time determine agent output quality, not natural-language prompt quality.
How to Structure a TypeScript Codebase for AI Coding Agents
- Define branded types for every domain identifier so the compiler rejects raw primitives at generation time.
- Create constructor functions with runtime validation as the sole way to instantiate branded values.
- Write custom AST-based ESLint rules to enforce architectural boundaries like service-layer imports.
- Add RuleTester fixtures so agents see concrete valid and invalid examples in their context window.
- Configure a GitHub Actions CI workflow that runs type-check, lint, and tests in fail-early sequential order.
- Enable branch protection requiring all CI checks to pass before any PR merges to main.
- Place an AGENTS.md file at the repository root documenting conventions, boundaries, and type constructors.
- Review AGENTS.md each sprint to keep guidance aligned with new types, rules, and boundaries.
Table of Contents
- Why Your Codebase Architecture Is Now an AI Prompt
- Prerequisites
- The Guardrail Hierarchy: Where to Invest for Agent-Friendly Codebases
- Layer 1: Enforce Domain Invariants with TypeScript Branded Types
- Layer 2: Catch Convention Violations with a Custom ESLint Rule
- Layer 3: Gate Merges with a GitHub Actions CI Workflow
- Layer 4: Guide Agent Behaviour with AGENTS.md
- Putting It All Together: The Runnable Scaffold Repository
- Checklist: Is Your Codebase Agent-Ready?
- Make the Right Path the Only Path
Why Your Codebase Architecture Is Now an AI Prompt
AI coding agents — Copilot, Cursor, Codex, Devin, and their successors — are now standard tooling in many engineering teams. They generate production code, shifting the developer’s role from writer to reviewer. That shift exposes a specific failure mode: natural-language prompt quality does not primarily determine agent output quality. The constraints a codebase enforces at compile time and CI time determine it.
Engineers who run AI coding agents in production, including Lauren Tan (@poteto), have argued publicly that traditional codebase hygiene is the highest-leverage investment for agent correctness. The core argument: making the correct path the easiest one, through types, invariants, and architectural boundaries, beats prompt engineering or post-hoc code review. When a compiler rejects invalid code before a pull request is even opened, no amount of conversational finesse matters.
When a compiler rejects invalid code before a pull request is even opened, no amount of conversational finesse matters.
This tutorial builds a minimal, runnable repository scaffold that demonstrates four complementary guardrails: branded types that enforce domain invariants at compile time, a custom ESLint rule that catches convention violations through static analysis, a GitHub Actions CI gate that prevents non-conforming code from merging, and an AGENTS.md guidance file that provides soft contextual nudges agents can read. These layers enforce constraints at four different points between code generation and merge, and each layer reinforces the others.
Prerequisites
Before following this tutorial, ensure you have the following installed locally:
Node.js 20 or later is required. The project assumes TypeScript 4.5+ (the branded type pattern uses unique symbol, available since TS 2.7, but modern TS is expected). You will also need ESLint v9, since this tutorial uses the flat config API and context.filename, both v9 features. Install @typescript-eslint/parser for the RuleTester examples, and have a test runner available — Vitest, Jest, or the built-in Node.js test runner all work.
Install the core dev dependencies:
npminstall --save-dev typescript eslint @typescript-eslint/parserThe Guardrail Hierarchy: Where to Invest for Agent-Friendly Codebases
Why Agents Default to the Loosest Valid Pattern
AI coding agents generate code that satisfies the immediate request. They lack whole-codebase awareness of unwritten conventions, team norms, or architectural decisions that exist only in developers’ heads. Without machine-enforceable constraints, an agent defaults to the loosest valid pattern. Asked to fetch a user, it will accept a raw string parameter rather than seeking out a branded UserId type. It will import the database client directly rather than routing through a service layer. The agent is not being careless. It optimizes for the shortest path to syntactically valid code.
The Leverage Ranking
A useful way to organize guardrails is as a hierarchy ordered by enforcement strength and feedback speed:
- Types and compiler checks. Zero runtime overhead. The editor surfaces type errors before the agent completes its generation pass — forcing correction before any code leaves the editor. This is the highest-leverage layer.
- Lint rules and static analysis. Custom ESLint rules catch convention violations the type system cannot express: import boundaries, naming patterns, forbidden API usage. They run fast and produce actionable error messages that point the agent toward the correct pattern.
- CI gates and branch protection. A GitHub Actions workflow runs type-checking, linting, and tests on every pull request. Branch protection rules ensure no code — human-authored or agent-generated — reaches
mainwithout passing. This is the final automated checkpoint. - Documentation and agent-specific guidance (AGENTS.md). A structured Markdown file at the repository root provides natural-language context that agent frameworks can ingest as system-level instructions, when the framework supports it. The softest layer, but it fills gaps the other three cannot cover.
Each layer compounds the effectiveness of the others. Types catch the majority of structural errors. Lint rules catch convention drift that types miss. CI gates catch anything that slips past local tooling. Documentation explains the why behind constraints, helping agents make better choices in ambiguous situations.
Layer 1: Enforce Domain Invariants with TypeScript Branded Types
What Are Branded Types and Why Agents Need Them
Consider a common function signature: function getUser(id: string). That parameter accepts any string. An email address, a raw UUID, an order ID, an empty string. An AI agent generating a call to getUser will happily pass whatever string-shaped value is closest at hand, with no compiler objection.
Branded types solve this by intersecting a base type with a unique symbol tag, creating a compile-time-only distinction between structurally identical types. A UserId and an OrderId are both strings at runtime, but the compiler treats them as incompatible types. The technique carries zero runtime overhead because the compiler erases the brand during compilation.
Important: Any direct cast such as "value" as UserId anywhere in the codebase bypasses the brand guarantee entirely. The TypeScript compiler will not prevent this. To enforce constructor-only creation, add an ESLint rule (e.g., @typescript-eslint/consistent-type-assertions) configured to ban as casts to branded types.
Code: Creating UserId and OrderId Branded Types
declareconst __brand: unique symbol;typeBrand<T,Bextendsstring>=T&{readonly[__brand]:B};exporttypeUserId= Brand<string,"UserId">;exporttypeOrderId= Brand<string,"OrderId">;exportfunctioncreateUserId(raw:string): UserId {const uuidRegex =/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;if(!uuidRegex.test(raw)){const safe =String(raw).replace(/[x00-x1fx7f]/g,"").slice(0,64);thrownewError(`Invalid UserId format: "${safe}"`);}return raw as UserId;}exportfunctioncreateOrderId(raw:string): OrderId {const orderIdRegex =/^ORD-[A-Z0-9]{8}$/;if(!orderIdRegex.test(raw)){const safe =String(raw).replace(/[x00-x1fx7f]/g,"").slice(0,64);thrownewError(`Invalid OrderId format: "${safe}"`);}return raw as OrderId;}exportfunctiongetUser(id: UserId):{ id: UserId; name:string}{return{ id, name:"Alice"};}How This Constrains an AI Agent
When an agent generates a call to getUser, the TypeScript compiler immediately rejects a raw string argument. The error message in the agent’s context window points to the type mismatch, forcing the agent to locate or create a UserId through the createUserId constructor function, which automatically enforces format validation. The agent does not need to know the convention. The type system prevents the wrong path. Every domain identifier that uses a branded type becomes a self-documenting, self-enforcing contract that agents must satisfy to produce code that compiles.
The agent does not need to know the convention. The type system prevents the wrong path.
Layer 2: Catch Convention Violations with a Custom ESLint Rule
Choosing What to Lint
Not every team convention belongs in a lint rule. Good candidates share a common trait: they can be detectedtive. Banning direct imports from internal modules, enforcing barrel-file usage, and preventing any in specific directories are all strong fits. Rules that require judgment calls or contextual understanding are poor candidates
This tutorial focuses on a rule that forbids importing from @/db outside the @/services directory, enforcing a service-layer boundary. Without this rule, an agent scaffolding a new HTTP handler would likely import the database client directly, bypassing the service layer and scattering data-access logic across the codebase.
Note: This rule requires ESLint v9 with flat config. The context.filename property is the ESLint v9 flat-config API. The legacy context.getFilename() is deprecated in v9.
Code: Writing the AST-Based ESLint Rule
importpathfrom"path";const rule ={meta:{type:"problem",docs:{description:"Disallow importing from @/db outside of the src/services directory",},messages:{noDirectDbImport:"Do not import from '@/db' outside of src/services/. "+"Use a service function from @/services instead.",},schema:[],},create(context){return{ImportDeclaration(node){const importSource =typeof node.source.value==="string"? node.source.value:"";if(importSource !=="@/db"&&!importSource.startsWith("@/db/")){return;}const filename = context.filename??"";if(filename ==="<input>"|| filename ===""){return;}const normalized = filename.replace(/\/g,"/");if(!normalized.includes("src/services/")){context.report({node,messageId:"noDirectDbImport",});}},};},};exportdefault rule;Note for Windows users: The filename.replace(/\/g, "/") normalization handles Windows backslash separators and mixed-separator strings reliably.
Code: Validating the Rule with RuleTester
import{RuleTester}from"eslint";importparserfrom"@typescript-eslint/parser";importrulefrom"../no-direct-db-import.js";const ruleTester =newRuleTester({languageOptions:{parser: parser,},});ruleTester.run("no-direct-db-import", rule,{valid:[{code:`import { findUser } from "@/db";`,filename:"src/services/userService.ts",},{code:`import { client } from "@/db/client";`,filename:"src/services/orderService.ts",},{code:`import { connect } from "@/database";`,filename:"src/handlers/userHandler.ts",},],invalid:[{code:`import { findUser } from "@/db";`,filename:"src/handlers/userHandler.ts",errors:[{messageId:"noDirectDbImport"}],},{code:`import { pool } from "@/db/client";`,filename:"src/handlers/orderHandler.ts",errors:[{messageId:"noDirectDbImport"}],},{code:`import { findUser } from "@/db";`,filename:"src/index.ts",errors:[{messageId:"noDirectDbImport"}],},],});When an agent’s context window includes the test file, RuleTester fixtures serve as living documentation: the agent can see concrete examples of what is permitted and what is not. This doubles as both verification and guidance.
An agent scaffolding a new handler tries to query the database directly by importing from @/db. The lint step fails with: “Use a service function from @/services instead.” That message points the agent toward the correct architectural pattern — immediate, actionable, and unambiguous. The agent does not need to have read a wiki page or remembered a Slack conversation.
Layer 3: Gate Merges with a GitHub Actions CI Workflow
Designing the Workflow for Agent-Generated PRs
Agents frequently push code to feature branches and open pull requests automatically. CI must run before any human review to surface issues cheaply and quickly. The workflow follows a fail-early sequential ordering: type-check first, then lint, then test. If the type-check fails, there is no point running linting or tests on code that does not compile. Because steps in a single job are sequential, any failing step halts the job immediately.
Code: The GitHub Actions Workflow File
name: Agent Guardrailson:pull_request:branches:[main]jobs:guardrails:runs-on: ubuntu-latesttimeout-minutes:10steps:-name: Checkout codeuses: actions/checkout@v4-name: Setup Node.jsuses: actions/setup-node@v4with:node-version:20cache: npm-name: Install dependenciesrun: npm ci-name: Type-checkrun: npx tsc --noEmit-name: Lintrun: npx eslint .-name: Testrun: npm testBranch Protection as the Final Lock
The CI workflow alone is not sufficient. Without branch protection, a determined agent or developer can merge a pull request despite failures. In the GitHub repository settings under Branches, enable branch protection for main: require status checks to pass before merging, specifically require the guardrails job from the Agent Guardrails workflow, and disable bypass permissions for bot accounts. This combination ensures that no code reaches the default branch without satisfying every automated check. The workflow becomes a hard gate rather than an advisory signal.
Note: Some branch protection features (such as restricting bypass permissions) may require a paid GitHub plan for private repositories.
Layer 4: Guide Agent Behaviour with AGENTS.md
What Is AGENTS.md and Which Agents Read It
AGENTS.md is a convention for placing natural-language guidance at the repository root that agent frameworks can ingest as system-level context. Variations of this convention include CLAUDE.md for Claude Code and .cursorrules for Cursor. OpenAI Codex CLI also reads AGENTS.md from the repository root. The agents and tools that currently support reading repository-level guidance files include OpenAI Codex CLI, Claude Code, Cursor, and Windsurf. The agent framework loads the content into the agent’s context window alongside the codebase when supported, making it available during code generation without requiring explicit prompting.
Code: A Practical AGENTS.md File
# AGENTS.md — Agent Guidance for agent-guardrails-scaffold## Project OverviewTypeScript Node.js service with strict domain boundaries.All code must pass `tsc --noEmit`, `eslint .`, and `npm test` before merge.## Architecture Boundaries- Database access (`@/db`) is ONLY permitted inside `src/services/`.- Handlers in `src/handlers/` must call service functions, never query the DB directly.- The ESLint rule `no-direct-db-import` enforces this boundary.## Type Conventions- Domain identifiers use branded types defined in `src/types/brand.ts`.- NEVER pass raw strings where a branded type is expected.- Use `createUserId()` and `createOrderId()` constructor functions to create branded values.- These constructors validate format; do not cast strings directly.## Testing Requirements- Every service function must have at least one unit test.- ESLint rule fixtures in `eslint-rules/__tests__/` must cover valid and invalid cases.## PR Checklist- [ ] No `tsc` errors- [ ] No ESLint violations- [ ] All tests pass- [ ] New domain identifiers use branded types- [ ] New data access goes through src/services/AGENTS.md Is a Soft Guardrail: Don’t Rely on It Alone
Agents may ignore, truncate, or misinterpret documentation. For example, Claude Code truncates files that exceed its context window limit, and instructions that work with one model version may produce different behavior after an update. AGENTS.md works best as a complement to compiler and linter enforcement, not a replacement. It fills gaps that hard enforcement cannot cover: explaining why a boundary exists, pointing to specific file paths, and providing checklists that aid both agents and human reviewers.
A practical maintenance habit: review and update AGENTS.md during sprint retrospectives. As new branded types, lint rules, or architectural boundaries are added, the guidance file must reflect them, or agents will lack the context to follow new conventions.
Putting It All Together: The Runnable Scaffold Repository
The complete scaffold follows this directory structure:
agent-guardrails-scaffold/├── src/│ ├── types/brand.ts│ ├── services/userService.ts│ └── handlers/userHandler.ts├── eslint-rules/no-direct-db-import.js├── eslint-rules/__tests__/no-direct-db-import.test.js├── .github/workflows/agent-guardrails.yml├── AGENTS.md├── tsconfig.json├── eslint.config.mjs└── package.jsonThe ESLint flat config file wires the custom rule into the project:
importnoDirectDbImportfrom"./eslint-rules/no-direct-db-import.js";importtsParserfrom"@typescript-eslint/parser";exportdefault[{ignores:["node_modules/**","dist/**"],},{files:["src/**/*.ts"],languageOptions:{parser: tsParser,ecmaVersion:"latest",sourceType:"module",},plugins:{"custom-rules":{rules:{"no-direct-db-import": noDirectDbImport,},},},rules:{"custom-rules/no-direct-db-import":"error",},},{files:["eslint-rules/**/*.js"],languageOptions:{ecmaVersion:"latest",sourceType:"module",},rules:{"no-undef":"error","no-unused-vars":"warn",},},];Your package.json should include at minimum the following scripts and dev dependencies:
{"scripts":{"test":"node --test 'eslint-rules/__tests__/**/*.test.js'"},"devDependencies":{"typescript":"^5.0.0","eslint":"^9.0.0","@typescript-eslint/parser":"^8.0.0"}}Note: The "test" script above uses the built-in Node.js test runner (Node 20+) with an explicit glob pattern to ensure test file discovery works reliably across Node versions. Replace with your preferred test runner (e.g., vitest run or jest) if needed.
Your tsconfig.json should include path aliases to support the @/ import convention used in the codebase:
{"compilerOptions":{"strict":true,"noEmit":true,"baseUrl":".","paths":{"@/*":["./src/*"]}},"include":["src"]}To run this scaffold locally, create the directory structure above, add each file from the code blocks in this tutorial, install the dev dependencies, and run npm install && npm test to verify all guardrails pass. From there, adapt the branded types and lint rules to match your domain.
Checklist: Is Your Codebase Agent-Ready?
- ☐ Domain identifiers use branded types, not raw primitives.
- ☐ Direct
ascasts to branded types are banned by a lint rule. - ☐ Architectural boundaries are enforced by custom lint rules with RuleTester fixtures.
- ☐ CI runs type-check, lint, and tests on every PR in fail-early sequential order.
- ☐ Branch protection requires CI to pass before merge.
- ☐ An AGENTS.md file documents conventions, boundaries, and type constructors.
- ☐ AGENTS.md is reviewed and updated at least once per sprint.
Make the Right Path the Only Path
Prompt instructions that work on GPT-4o may produce different results on Claude or on the next model update. Natural-language instructions degrade across context windows, model updates, and agent implementations. Structural enforcement through types, lint rules, and CI gates is durable. It works regardless of which agent generates the code, which model version powers it, or whether the human reviewer catches every subtlety.
Structural enforcement through types, lint rules, and CI gates is durable. It works regardless of which agent generates the code, which model version powers it, or whether the human reviewer catches every subtlety.
The practical path forward is incremental. Start with one branded type for a critical domain identifier and one lint rule for the most frequently violated architectural boundary. Expand coverage as agent usage grows and new patterns of convention drift emerge. Clone the scaffold, adapt it to your domain, and let the compiler do the reviewing.
Sharing our passion for building incredible internet things.


