K
Kushan Shah
All writing
Building with Agents

Spec-Driven Engineering: How to Write Specs That Agents (and Humans) Can Build From

|11 min read
EngineeringSpecsAgentic AIBuild Log

In my post on agentic engineering, I argued that the bottleneck has shifted from writing code to writing specs. When an AI agent can implement a well-specified feature end-to-end, the spec becomes the most important artifact in the development process.

But what does a good spec actually look like? After writing dozens of them for production systems, I've developed a set of practices that consistently produce specs engineers (and agents) can build from directly, without follow-up Slack threads or ambiguity.

Throughout this post, I'll use a notification engine as the running example: a multi-channel, multi-tenant system that handles transactional alerts, marketing digests, and quiet hours. It's complex enough to exercise every practice, and generic enough that the patterns translate to whatever you're building.

Here's the framework.

ANATOMY OF A SPEC1. Overview TableTL;DR contract2. Input EnumerationSource + optionality3. API ContractsBoth languages4. Pipeline StagesDiagram + pseudo-code5. Data ExamplesReal values, not schemas6. Gap AnalysisBuild vs. exists7. Phase PlanIndependently shippableALSO INCLUDEWireframesASCII, not FigmaState MachinesTyped enumsVariantsEvery mode spelled outExclusionsWhat you're NOT buildingA 10,000-word spec thatengineers can build from
Every spec should cover these elements. The left column is the core structure; the right column completes it.

In this post:

  1. Start with the overview table: the TL;DR contract
  2. Enumerate every input: source, fields, optionality
  3. Use ASCII wireframes: version-controlled, structure-focused
  4. Define parallel API contracts: both languages, field-aligned
  5. Visualize the pipeline: diagram for shape, detail for depth
  6. Include data examples: real values, not type signatures
  7. Map gaps to existing systems: build vs. reuse
  8. Spec every variant: no "general case" hand-waving
  9. Include a phase plan: independently shippable milestones
  10. List what you're NOT building: scope is defined by exclusions

1. Start with the overview table

Every spec should open with a dense table that gives readers the key parameters at a glance. An engineer should be able to read this table and know whether the spec is relevant to them, what scale they're designing for, and what the output looks like.

AttributeValue
FeatureOrder Notification System
ChannelsEmail, SMS, Push, In-App
Volume~200K notifications/day
Latency TargetUnder 30 seconds for transactional
BatchingDigest every 4 hours for marketing
TenancyMulti-tenant, per-tenant templates
DependenciesEvent bus, user preferences, email provider

This is the "TL;DR contract" of the spec. It prevents people from reading three pages before understanding what they're building. Include volume, latency targets, channels, and key dependencies: the constraints that shape architecture decisions.


2. Enumerate every input

One of the most common spec failures is hand-waving over inputs. "The system takes order data" tells engineers nothing. Break inputs into a structured table with four columns: what the data is, what fields it contains, where it comes from, and whether it's required.

InputFieldsSourceRequired
Order eventorder_id, status, items, totalEvent busRequired
Customer prefschannels[], quiet_hours, langPreferences APIRequired
Tenant configtemplates, branding, senderConfig serviceRequired
Delivery addresscity, state, postal_codeOrder serviceOptional
Product imagesthumbnail_url, alt_textCatalog serviceOptional

The Source column forces you to think about where the data actually lives and whether that system exists yet. If the source says "Catalog service" and you don't have one, you've just identified a dependency. The Required/Optional column draws a clear line between MVP and enhancements.


3. Use ASCII wireframes for UI flows

ASCII wireframes are superior to image mockups in specs for three reasons:

  • They live in version control and diff cleanly
  • They force you to focus on structure over aesthetics
  • They capture layout, hierarchy, data, and interactions in one artifact
+---------------------------------------------------------------+
|  <- Back to Settings                              [ Save ]    |
|---------------------------------------------------------------|
|                                                               |
|   Notification Preferences                                    |
|                                                               |
|   +-------------------------------------------------------+   |
|   |  Channels                                      [ pen ]|   |
|   |  ---------------------------------------------------  |   |
|   |  Email:    [x] Transactional  [x] Marketing           |   |
|   |  SMS:      [x] Transactional  [ ] Marketing           |   |
|   |  Push:     [x] Transactional  [x] Marketing           |   |
|   +-------------------------------------------------------+   |
|                                                               |
|   +-------------------------------------------------------+   |
|   |  Quiet Hours                                   [ pen ]|   |
|   |  ---------------------------------------------------  |   |
|   |  Start:   10:00 PM                                    |   |
|   |  End:     8:00 AM                                     |   |
|   |  Timezone: IST (auto-detected)                        |   |
|   +-------------------------------------------------------+   |
|                                                               |
|   [ Cancel ]                              [ Save Changes ]    |
+---------------------------------------------------------------+

Notice how the wireframe captures navigation (Back, Save), section structure (cards with edit icons), actual data labels with example values, and CTA positioning. This gives an engineer enough to build the skeleton without a Figma link.


4. Define parallel API contracts

When frontend and backend are developed in parallel, the spec is the contract. Define request and response schemas in both languages, with field-level alignment.

# Backend (Python/Pydantic)
class NotificationPrefsUpdate(BaseModel):
    channels: ChannelConfig
    quiet_hours: QuietHours | None = None
    digest_frequency: str = "4h"
// Frontend (TypeScript)
interface NotificationPrefsUpdate {
  channels: ChannelConfig;
  quietHours: QuietHours | null;
  digestFrequency: string;
}

Showing both sides forces you to handle the snake_case/camelCase translation explicitly and catches field mismatches before they become runtime bugs.

Add an error codes table for every endpoint. This is the contract for how failures are communicated:

CodeHTTPDescription
INVALID_CHANNEL400Unsupported notification channel
QUIET_HOURS_OVERLAP422Start time is after end time
TENANT_TEMPLATE_MISSING404No template configured for tenant

Without this, frontend and backend will invent different error handling patterns.


5. Visualize the pipeline as stages

For any multi-step process, break it into numbered stages. Each stage documents its inputs, processing, and outputs.

STAGE 1ValidateSTAGE 2TransformSTAGE 3ProcessSTAGE 4DeliverCheck inputsReject invalidNormalize dataMap fieldsCore logicHandle edge casesFormat outputNotify + logDiagram for the shape. Pseudo-code for the detail. Readers zoom in or out as needed.
Break every multi-step process into numbered stages. Each stage documents inputs, processing, and outputs.

Below the diagram, expand each stage with pseudo-code:

# Stage 2: Transform
# Inputs: validated event, customer prefs, tenant config

# Processing:
#   1. Select template based on event type + tenant
#   2. Render template with order data
#   3. Determine delivery channels from prefs
#   4. Apply quiet hours (defer if within window)
#   5. Check digest eligibility (batch if marketing)

# Output: list of DeliveryTask(channel, recipient, content, send_at)

This pattern lets readers zoom in or out depending on what they need. Product managers read the diagram. Engineers read the pseudo-code. Both get what they need from the same spec.


6. Include data examples, not just schemas

Schemas tell you the shape. Examples tell you the content. Always include at least one fully populated JSON example alongside your schema definitions.

{
  "notification_id": "ntf_8x2kp9",
  "event_type": "order_shipped",
  "recipient": {
    "user_id": "usr_4f2a",
    "email": "[email protected]",
    "phone": "+91-98765-43210",
    "preferred_language": "en"
  },
  "content": {
    "subject": "Your order #ORD-1234 has shipped",
    "body": "Expected delivery: April 15, 2026",
    "cta_url": "https://track.example.com/ORD-1234"
  },
  "delivery": {
    "channels": ["email", "push"],
    "send_at": "2026-04-13T14:30:00Z",
    "digest_eligible": false
  }
}

The example does things the schema cannot:

  • It shows realistic values (not just "string")
  • It reveals the actual shape of nested objects
  • It acts as test data for engineers building the feature

When you see "channels": ["email", "push"] you immediately know it's a multi-select, not a single choice. The schema channels: string[] doesn't tell you that.


7. Map gaps to existing systems

Every spec introduces new work. A gap analysis table makes explicit what already exists, what partially exists, and what needs to be built from scratch. This is the foundation for effort estimation.

ComponentCurrent StateGap
Event busExists (Kafka)None
User preferencesExists (Preferences API)Add channel config
Email deliveryExists (SendGrid)None
SMS deliveryNot supportedBuild + vendor select
Push notificationsPartial (Firebase)Add template support
Digest batchingNot supportedBuild from scratch
Template engineNot supportedBuild or buy

This table prevents the "everything is new" illusion: in this example, half the infrastructure already exists. Without the mapping, the project looks twice as large as it actually is.

It also prevents the "this is easy" illusion by surfacing components marked "Not supported."


8. Spec every variant explicitly

When a feature has multiple modes, don't describe the "general case" and hope readers extrapolate. Spell out each variant with its specific behavior.

Transactional (e.g., order_shipped):

  • Immediate delivery, all opted-in channels
  • No batching, no quiet hours override
  • Template: "Your order #{order_id} has shipped"

Marketing (e.g., weekly_picks):

  • Digest-eligible, email + push only (no SMS)
  • Respects quiet hours
  • Template: "This week's picks for you"
  • Includes unsubscribe link

System (e.g., password_reset):

  • Immediate delivery, email only
  • Bypasses quiet hours, no unsubscribe option
  • 15-minute expiry on link

Notice how the system variant bypasses quiet hours and has no unsubscribe option, while marketing requires both. These structural differences are invisible in a generic spec but critical for implementation. Each variant is a distinct code path.


9. Include a phase plan

Specs should answer "what do we build first?" A phased plan prevents the feature from being built bottom-up (infrastructure first, user value last).

Phase 1: Core Flow (MVP)

  • Event bus consumer for order events
  • Email delivery via SendGrid
  • Single default template per event type
  • Basic preferences (opt-in/opt-out per channel)

Phase 2: Multi-Channel + Templates

  • SMS delivery via Twilio
  • Push notifications via Firebase
  • Per-tenant template engine
  • Quiet hours support

Phase 3: Digest + Intelligence

  • Marketing digest batching (4-hour window)
  • Channel optimization (best channel per user)
  • Delivery analytics dashboard

Each phase should be independently shippable. Phase 1 delivers working email notifications. Phase 2 adds channels and customization. Phase 3 adds intelligence. This structure lets engineering and product negotiate scope at phase boundaries rather than mid-feature.


10. List what you're NOT building

Scope is defined as much by what's excluded as what's included. A "Not building" section prevents scope creep and prevents future readers from wondering why something was left out.

ExcludedWhy
In-app notification centerSeparate initiative, different team
ML-based send time optimizationPhase 3+ after delivery data is available
WhatsApp channelVendor approval pending, revisit Q3
Read receipts for SMSNo reliable API, not worth the complexity

The "Why" column is essential. Without it, someone will re-propose the excluded item in a future sprint. With it, they can evaluate whether the original reasoning still holds.


The bottom line

A good spec eliminates ambiguity. The practices above share a single philosophy: be concrete, be structured, and make the implicit explicit.

What you writeWhat it prevents
Overview tableThree pages before anyone understands the scope
Input enumeration"Where does this data come from?" in Slack
ASCII wireframesFigma links that break and can't be diffed
Dual-language contractssnake_case/camelCase bugs at integration
Pipeline diagrams"Wait, which service calls which?"
Data examplesSchemas that look right but produce wrong output
Gap analysis"Everything is new" or "this is easy" illusions
Variant specsHidden code paths discovered during QA
Phase plansInfrastructure-first builds that never ship value
Exclusion listsScope creep from well-meaning re-proposals

A 2,000-word spec that requires 40 hours of Slack clarification is worse than a 10,000-word spec that engineers can build from directly. Write the spec you wish you had when you joined the project.

And when an AI agent is the one building from the spec, the bar is even higher. Agents don't ask for clarification. They make assumptions. Every ambiguity in the spec becomes a coin flip in the implementation. These practices don't just make specs better for humans. They make specs executable.


This post is a companion to Agentic Engineering with Claude Code, which covers the spec-first workflow in practice. For context engineering techniques that improve spec-writing conversations with AI, see Context Engineering > Prompt Engineering.

Related writing