Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
Codebase Design for AI Agents: Types, Lint Rules, and CI Gates
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
Start Free Trial

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

  1. Define branded types for every domain identifier so the compiler rejects raw primitives at generation time.
  2. Create constructor functions with runtime validation as the sole way to instantiate branded values.
  3. Write custom AST-based ESLint rules to enforce architectural boundaries like service-layer imports.
  4. Add RuleTester fixtures so agents see concrete valid and invalid examples in their context window.
  5. Configure a GitHub Actions CI workflow that runs type-check, lint, and tests in fail-early sequential order.
  6. Enable branch protection requiring all CI checks to pass before any PR merges to main.
  7. Place an AGENTS.md file at the repository root documenting conventions, boundaries, and type constructors.
  8. 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

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:

npm install --save-dev typescript eslint @typescript-eslint/parser

The 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:

  1. 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.
  2. 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.
  3. 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 main without passing. This is the final automated checkpoint.
  4. 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

// src/types/brand.ts

declare const __brand: unique symbol;

type Brand<T, B extends string> = T & { readonly [__brand]: B };

// Domain-specific branded types
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;

// Constructor/validation functions — the ONLY way to create branded values
export function createUserId(raw: string): UserId {
  // WARNING: Direct casts (e.g., "value" as UserId) bypass this validation.
  // Enforce constructor-only creation with a lint rule banning as-casts to branded types.
  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)) {
    // Truncate and strip control characters before interpolating into error message
    const safe = String(raw).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 64);
    throw new Error(`Invalid UserId format: "${safe}"`);
  }
  return raw as UserId;
}

export function createOrderId(raw: string): OrderId {
  // Validates prefix "ORD-" plus exactly 8 uppercase alphanumeric characters.
  const orderIdRegex = /^ORD-[A-Z0-9]{8}$/;
  if (!orderIdRegex.test(raw)) {
    const safe = String(raw).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 64);
    throw new Error(`Invalid OrderId format: "${safe}"`);
  }
  return raw as OrderId;
}

// This function ONLY accepts a UserId — not a plain string, not an OrderId
export function getUser(id: UserId): { id: UserId; name: string } {
  // database lookup...
  return { id, name: "Alice" };
}

// ✅ Correct usage:
// const uid = createUserId("550e8400-e29b-41d4-a716-446655440000");
// getUser(uid);

// ❌ Compiler error — agent is forced to use createUserId():
// getUser("550e8400-e29b-41d4-a716-446655440000");
// Type 'string' is not assignable to type 'UserId'.

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 detected via AST inspection and have a clear, unambiguous correct alternative. 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

// eslint-rules/no-direct-db-import.js

import path from "path";

/** @type {import("eslint").Rule.RuleModule} */
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 : "";

        // Exact match or sub-path; avoids matching @/dbadmin, @/database, etc.
        if (
          importSource !== "@/db" &&
          !importSource.startsWith("@/db/")
        ) {
          return;
        }

        const filename = context.filename ?? "";

        // RuleTester supplies "<input>" when filename is not specified per fixture;
        // skip enforcement for virtual files to avoid false positives in tests.
        if (filename === "<input>" || filename === "") {
          return;
        }

        // Normalize all separator variants to forward slash for cross-platform safety.
        const normalized = filename.replace(/\\/g, "/");

        if (!normalized.includes("src/services/")) {
          context.report({
            node,
            messageId: "noDirectDbImport",
          });
        }
      },
    };
  },
};

export default 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

// eslint-rules/__tests__/no-direct-db-import.test.js

import { RuleTester } from "eslint";
import parser from "@typescript-eslint/parser";
import rule from "../no-direct-db-import.js";

const ruleTester = new RuleTester({
  languageOptions: {
    parser: parser,
  },
});

ruleTester.run("no-direct-db-import", rule, {
  valid: [
    {
      code: `import { findUser } from "@/db";`,
      filename: "src/services/userService.ts",
    },
    {
      // Sub-path import from service is also valid
      code: `import { client } from "@/db/client";`,
      filename: "src/services/orderService.ts",
    },
    {
      // @/database must NOT be caught (prefix boundary check)
      code: `import { connect } from "@/database";`,
      filename: "src/handlers/userHandler.ts",
    },
  ],
  invalid: [
    {
      code: `import { findUser } from "@/db";`,
      filename: "src/handlers/userHandler.ts",
      errors: [{ messageId: "noDirectDbImport" }],
    },
    {
      // Sub-path @/db/client also blocked outside services
      code: `import { pool } from "@/db/client";`,
      filename: "src/handlers/orderHandler.ts",
      errors: [{ messageId: "noDirectDbImport" }],
    },
    {
      // Root-level file (not under src/services/) is blocked
      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.

How This Constrains an AI Agent

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

# .github/workflows/agent-guardrails.yml

name: Agent Guardrails

on:
  pull_request:
    branches: [main]

jobs:
  guardrails:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      # Steps halt the job on first failure by default.
      - name: Type-check
        run: npx tsc --noEmit

      - name: Lint
        run: npx eslint .

      - name: Test
        run: npm test

Branch 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 Overview
TypeScript 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.json

The ESLint flat config file wires the custom rule into the project:

// eslint.config.mjs

import noDirectDbImport from "./eslint-rules/no-direct-db-import.js";
import tsParser from "@typescript-eslint/parser";

export default [
  {
    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 as casts 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.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.