AI / ML ·

Spec-Driven Development for AI Engineering Teams: Writing Specifications That AI Agents Can Execute

Vague prompts produce vague code. This guide covers how to structure specifications for AI coding agents: acceptance criteria, interface contracts, constraints, test cases, and a complete spec-to-code workflow with real TypeScript examples showing good vs bad specs and their actual output quality.

Spec-Driven Development for AI Engineering Teams: Writing Specifications That AI Agents Can Execute

The output quality of an AI coding agent is a direct function of the input quality of its specification. That is the central insight behind Spec-Driven Development, and most engineering teams adopting AI tooling are still ignoring it.

Teams introduce a coding agent, watch it produce decent boilerplate, and then grow frustrated when it generates subtly wrong logic, skips edge cases, or produces code that passes cursory review but fails in production. The usual response is to prompt it more. The actual fix is to write better specifications before touching the agent at all.

This is not a new idea rebranded. Writing precise specifications has been a core software engineering discipline for decades. What changed is that the consumer of those specifications is now a language model, not a human, and language models have specific failure modes that a well-structured spec can preempt.

Why Vague Prompts Produce Vague Code

Language models generate code by predicting the most statistically likely continuation of a prompt. When a prompt is ambiguous, the model fills that ambiguity with the most common pattern it has seen. Common patterns are generic. Generic code is usually wrong in the ways that matter most: it handles the happy path, ignores your constraints, and makes assumptions about your domain that are subtly incorrect.

Consider this prompt:

Write a function that processes payments.

The model will produce something. It will probably look reasonable. It will miss:

  • Which payment provider you are using and its specific error codes
  • Whether idempotency is required
  • How partial failures should be handled
  • What the retry policy is
  • Whether the function should be synchronous or async
  • What the caller expects in the return type on failure

None of these are exotic requirements. Every production payment processor deals with all of them. But the model cannot infer them from a four-word prompt, so it defaults to a pattern it has seen before, which is a simplified tutorial-grade implementation.

The same prompt given to a senior engineer produces a clarifying conversation, not immediate code. The model has no mechanism to ask clarifying questions during code generation unless you build that loop explicitly. The spec is the substitute for that conversation.

The Anatomy of an Executable Specification

A specification that AI agents can execute reliably has five components. Missing any one of them increases the probability of wrong output.

1. Context and Intent

State why this code exists and what system it lives in. Not the what, the why. A model that understands context makes better tradeoff decisions.

Bad:

Write a rate limiter.

Good:

We are building a rate limiter for a public REST API that serves third-party integrations.
The API runs on Node.js with Redis. We need per-customer rate limiting (not per-IP) using
sliding window counts. This is middleware that will run on every request.

The good version tells the model what constraints are real: Redis is available, per-customer is the unit, sliding window is the algorithm, middleware is the integration pattern.

2. Interface Contract

Define inputs, outputs, and types before you write a single line of implementation. This is where TypeScript types pay for themselves in a specification context. An AI agent with a precise type contract will conform to it. Without one, it invents types that seem reasonable to it.

// Specification: interface contract for the rate limiter
interface RateLimiterConfig {
  windowMs: number;       // sliding window duration in milliseconds
  maxRequests: number;    // maximum requests allowed per window per customer
  redisClient: Redis;     // pre-initialized Redis client
}

interface RateLimitResult {
  allowed: boolean;
  remaining: number;      // requests remaining in current window
  resetAt: Date;          // when the current window resets
  retryAfter?: number;    // milliseconds to wait if not allowed
}

// Function signature the implementation must satisfy:
async function checkRateLimit(
  customerId: string,
  config: RateLimiterConfig
): Promise<RateLimitResult>

Giving this contract to a coding agent removes a large class of decisions. The agent is not choosing a return shape or inventing field names. It is implementing to a spec.

3. Acceptance Criteria

Write explicit behavioral requirements as a numbered list. Format these so they can become test cases directly.

Acceptance Criteria:
1. Returns allowed: true when the customer has not exceeded maxRequests in the current window.
2. Returns allowed: false when the customer has reached or exceeded maxRequests.
3. The `remaining` field must reflect requests left after counting the current request.
4. `resetAt` must be the exact timestamp when the oldest request in the window will expire.
5. When allowed is false, `retryAfter` must be the milliseconds until the next request would be allowed.
6. Two concurrent requests from the same customer must not both be allowed when only one slot remains (no race condition).
7. A Redis connection failure must throw a RateLimiterError, not silently allow the request.

Criterion 6 and 7 are the ones a generic implementation will miss. Without explicit criteria, the model implements the happy path. The criteria force it to address the edge cases you actually care about.

4. Constraints

State what the implementation must not do. Models optimize for working code; they do not know your operational constraints unless you tell them.

Constraints:
- Must use a single Redis pipeline per call (no multiple round-trips).
- Must not use KEYS or SCAN commands (production Redis, no full-key scans).
- Must be idempotent: calling twice with the same parameters within the same millisecond must not double-count.
- Must not mutate the config object.
- Maximum cyclomatic complexity of 10 per function.
- No external dependencies beyond the Redis client already in scope.

The single-pipeline constraint is not something a model infers. Without it, you will get an implementation that does a GET, then a SET, then an EXPIRE in three round-trips, which is both slower and has a race condition window.

5. Test Cases as Examples

Provide at least one concrete input/output example. Concrete examples resolve ambiguity that abstract descriptions leave open.

Example:
- Customer "acme" has made 47 requests in the last 60 seconds.
- maxRequests is 50, windowMs is 60000.
- Input: customerId = "acme"
- Expected output:
  {
    allowed: true,
    remaining: 2,       // 50 - 47 - 1 (this request)
    resetAt: <timestamp of oldest request + 60000ms>,
    retryAfter: undefined
  }

Edge case:
- Customer "acme" has made 50 requests in the last 60 seconds.
- Input: customerId = "acme"
- Expected output:
  {
    allowed: false,
    remaining: 0,
    resetAt: <timestamp>,
    retryAfter: <ms until oldest request expires>
  }

The example makes remaining semantically unambiguous. Does remaining count before or after this request? The example answers it.

Good Spec vs Bad Spec: The Output Difference

Here is what these two approaches produce when fed to a coding agent.

Bad spec prompt:

Write a TypeScript rate limiter using Redis.

Typical output: a function using GET/SET with a fixed window, no TypeScript types, no error handling, assumes a global Redis client, and uses parseInt on Redis responses without null checks. It works in a unit test. It silently allows requests when Redis is down. It double-counts under concurrent load.

Good spec (all five components):

The model produces an implementation that:

  • Uses a Redis pipeline with ZADD, ZREMRANGEBYSCORE, and ZCARD in a single round-trip
  • Returns the exact RateLimitResult interface you defined
  • Throws a typed RateLimiterError on Redis failures
  • Handles the concurrent request case with a Lua script or pipeline atomic operations
  • Includes inline comments referencing the acceptance criteria numbers

The implementation surface area is not that different. The behavioral correctness gap is enormous.

Tradeoffs of Spec-Driven Development

DimensionHigh-quality specMinimal prompt
Time to first draftSlower (30-60 min spec writing)Fast (seconds)
Code correctnessHigh, matches production requirementsModerate, happy-path only
Edge case coverageExplicit, criterion-drivenImplicit, model-dependent
Review burdenLow (review against spec)High (discover gaps in review)
Spec reusabilityHigh (spec is documentation)None
Agent iteration loopsFew (spec resolves ambiguity upfront)Many (prompt, fix, prompt)
Works with junior reviewersYes (spec is the standard)No (requires deep context)

The time cost is front-loaded. A well-written spec takes 30-60 minutes for a non-trivial feature. That same feature with a minimal prompt typically requires multiple generation-review-refine cycles that add up to more total time, with worse output.

The hidden benefit is the spec becomes your documentation. What usually takes three or four generation cycles to get right also lacks documentation at the end. A spec-driven workflow produces a spec artifact that describes the component behavior at the interface and behavioral level, which is more useful than reading the code.

The Spec-to-Code Workflow

A repeatable workflow for teams adopting SDD looks like this:

Step 1: Write the interface contract first. Before you think about implementation, write the TypeScript types. This forces clarity on the API boundary.

Step 2: Write acceptance criteria as a numbered list. Make each criterion testable. If you cannot write a test for it, the criterion is too vague.

Step 3: Add constraints and context. What infrastructure is available? What are the performance requirements? What must the implementation not do?

Step 4: Write one or two concrete examples. These resolve the ambiguities your criteria leave open.

Step 5: Feed the complete spec to the coding agent. Paste the entire spec, not just the function signature. Instruct the agent to implement to the spec and flag any spec ambiguities before generating code.

Step 6: Review against the spec, not against your intuition. The spec is the source of truth. If the generated code does not satisfy criterion 6, it is wrong regardless of how clean it looks.

Step 7: Generate tests from the acceptance criteria. The criteria map directly to test cases. The agent can generate these too, given the spec.

// Example: feeding the spec as structured input
const spec = `
## Component: checkRateLimit

### Context
Per-customer sliding window rate limiter for a public API.
Runs as Express middleware. Redis is available via the injected client.

### Interface
${interfaceDefinition}

### Acceptance Criteria
${criteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}

### Constraints
${constraints.join('\n')}

### Examples
${examples}

Implement this spec. Flag any ambiguities before writing code.
`;

Structuring the spec in a consistent format helps when you are working across multiple agents or multiple sessions. The agent can parse the sections reliably.

Spec Validation Tooling

Beyond writing better specs, you can add lightweight tooling to enforce them.

Schema validation for spec artifacts. Define a schema for what a valid spec contains and validate before generation.

import { z } from "zod";

const SpecSchema = z.object({
  component: z.string().min(1),
  context: z.string().min(50),
  interface: z.object({
    inputs: z.array(z.string()),
    outputs: z.array(z.string()),
  }),
  acceptanceCriteria: z.array(z.string()).min(3),
  constraints: z.array(z.string()).min(1),
  examples: z.array(
    z.object({
      input: z.record(z.unknown()),
      expectedOutput: z.record(z.unknown()),
    })
  ).min(1),
});

function validateSpec(spec: unknown): asserts spec is z.infer<typeof SpecSchema> {
  SpecSchema.parse(spec); // throws ZodError with specific missing fields
}

Criteria-to-test tracing. Number your acceptance criteria and reference them in test names. This makes it trivial to audit which criteria have coverage.

describe("checkRateLimit", () => {
  it("AC-1: returns allowed=true when customer is under limit", async () => { /* ... */ });
  it("AC-2: returns allowed=false when customer is at limit", async () => { /* ... */ });
  it("AC-6: concurrent requests do not exceed limit", async () => { /* ... */ });
  it("AC-7: throws RateLimiterError on Redis failure", async () => { /* ... */ });
});

A test run report that maps to criterion numbers tells you exactly which behaviors are verified. A failing AC-6 test is a much cleaner signal than “rate limiter broken under load.”

Team Adoption: Incremental Path

Shifting a team to SDD does not require a process overhaul. Start with one high-leverage change at a time.

Week 1: Add interface contracts to all AI-generated code. Before any team member prompts a coding agent for a new function, require a TypeScript type definition for inputs and outputs. This alone meaningfully improves output quality.

Week 2: Add acceptance criteria to tickets. Make it a ticket requirement that any ticket that will be implemented with an AI agent includes at least three acceptance criteria in the ticket body. These feed directly into prompts.

Week 3: Introduce a spec template. Create a markdown template in your repo that structures context, interface, criteria, constraints, and examples. Make it the starting point for all AI-assisted feature work.

Week 4: Generate tests from criteria. Once the spec template is in use, add a step where the agent generates test stubs from the acceptance criteria before writing implementation code. This surfaces spec gaps before they become implementation bugs.

The common objection is that writing specs takes too long. The accurate comparison is not “time to write spec” versus “time to write prompt.” It is “time to write spec plus one generation” versus “time to iterate through five generations plus review plus post-merge fixes.” Spec writing is almost always faster in total wall clock time, and the output is better.

Production Considerations

A few SDD patterns that matter specifically at production scale:

Versioned specs. Treat specs as first-class artifacts in version control. When a component’s behavior changes, the spec changes first, then the implementation. This gives you a diff that explains why the code changed, not just what changed.

Spec coverage audits. Periodically run a query across your spec artifacts and test files to verify every acceptance criterion has a corresponding test. This is harder to do with prose prompts and much easier with numbered criteria in a consistent format.

Agent-specific spec sections. Different AI coding agents have different strengths and failure modes. Add a section to your spec template called “Agent notes” where you call out known agent tendencies to guard against. For example, a note like “do not use KEYS in Redis commands” is worth repeating even if it is in the constraints, because some agents reliably reach for KEYS on sliding window implementations.

Spec templates per component type. A spec for a REST endpoint looks different from a spec for a background job or a data migration. Build a small library of spec templates tuned to your common component types. The interface contract section of a background job spec will ask different questions than a middleware spec.

Closing

The bottleneck in AI-assisted engineering is not the model’s capability. The bottleneck is the quality of the context you give it. A well-structured spec resolves ambiguity before generation, forces you to think through the behavior you actually want, and produces output that is reviewable against a known standard rather than against your intuition.

The teams that will get the most value from AI coding agents in the next two years are not the ones with the best prompts. They are the ones that treat specification writing as a first-class engineering discipline.

More in AI / ML

How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
AI / ML ·

How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models

A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.

AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
AI / ML ·

AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems

A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.

Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
AI / ML ·

Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems

A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.

How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
AI / ML ·

How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs

A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.