Back to blog
#prd#templates#best-practices

PRD templates that actually work for AI agents

A good PRD template for agents needs: objective, context, requirements, acceptance criteria, out of scope. Here's the one I use for every task.

PRD templates that actually work for AI agents

Human PRDs vs agent PRDs

I've written PRDs for humans and I've written PRDs for AI agents. They look similar on the surface but the failure modes are completely different.

When a human dev reads a vague PRD, they walk over to your desk and ask a question. When an agent reads a vague PRD, it guesses. And then it builds the guess. And then you wake up to a feature that technically works but isn't what you wanted. This is why getting acceptance criteria right for AI agents matters so much, and why properly structured PRDs are worth the investment.

The template I'm about to share has survived hundreds of overnight pipeline runs. It's not clever. It's not long. But every section exists because I got burned by leaving it out.

The template

Here's the exact structure I use for every task that goes into a Zowl pipeline:

## Task: [Verb] [Specific thing] [Where/How]

### Objective
One sentence. What this task produces when it's done.

### Context
- Framework/language/version
- Relevant existing files (full paths)
- Database or API dependencies
- Current state of the feature (does anything exist already?)

### Requirements
1. First specific behavior
   - Input: what it receives
   - Output: what it returns
   - Error case: what happens when X goes wrong
2. Second specific behavior
   ...

### Acceptance Criteria
- [ ] Criterion 1 (must be testable)
- [ ] Criterion 2
- [ ] Existing tests still pass: `npm run test`
- [ ] No new lint warnings: `npm run lint`

### Out of Scope
- Thing that seems related but isn't part of this task
- Another thing the agent might try to add

### Notes
- Any gotchas, quirks, or preferences
- "Use X library, not Y"
- "Follow the pattern in src/lib/existing-thing.ts"

That's it. Six sections. Let me walk through why each one matters.

Objective: one sentence or you're overthinking it

### Objective
Add a PATCH /api/users/:id endpoint that updates
a user's profile fields.

One sentence. If you can't describe the task in one sentence, it's probably two tasks. Split it.

The objective isn't a description. It's a completion check. When this sentence is true, the task is done. I read it that way, and I write it that way.

Early on I used to write three-paragraph objectives with background context and motivation. The agents don't care about motivation. They care about what to build.

Context: pretend the agent just joined your team today

This section is the one most people skip. It's also the one that causes the most failures when it's missing.

### Context
- Next.js 14 App Router, TypeScript
- Database: Postgres via Prisma (schema at prisma/schema.prisma)
- Existing user routes at src/app/api/users/route.ts (GET, POST)
- Auth middleware at src/middleware.ts (validates JWT from cookie)
- Error handling pattern: src/lib/errors.ts (AppError class)

Every file path is real. Every dependency is named. The agent doesn't have to guess what framework you're using or where the existing code lives.

I learned this the hard way. I had a task that said "add a caching layer." The agent didn't know I was using Redis, which was already configured in src/lib/redis.ts. It set up an in-memory cache from scratch. Technically correct. Completely wrong for my project. Five lines of context would've prevented it.

The rule I follow: if a human joining the project would need to know it to do this task, put it in the context section.

Requirements: numbered, specific, with error cases

### Requirements
1. PATCH /api/users/:id
   - Accept JSON body with optional fields: { name, bio, avatarUrl }
   - Validate: name max 100 chars, bio max 500 chars,
     avatarUrl must be valid URL or null
   - Only the authenticated user can update their own profile
     (compare JWT user ID with :id param)
   - Return 200 with updated user object
   - Return 400 if validation fails (include field-level errors)
   - Return 403 if user ID doesn't match
   - Return 404 if user doesn't exist

Every requirement is numbered. Every input and output is specified. Every error case is covered.

The numbering matters. It gives the agent (and the validation step) something to reference. Instead of "the endpoint doesn't handle errors right," the validation can say "requirement #1 error case: 403 response not implemented." Specific feedback leads to specific fixes.

The biggest mistake I see is leaving out error cases. Happy path is easy. Agents are great at happy paths. It's the "what happens when the user sends garbage" scenarios that need to be written down.

Acceptance criteria: if you can't test it, rewrite it

### Acceptance Criteria
- [ ] PATCH /api/users/1 with valid body returns 200
- [ ] PATCH /api/users/1 with name > 100 chars returns 400
- [ ] PATCH /api/users/2 with user 1's token returns 403
- [ ] PATCH /api/users/999 returns 404
- [ ] Existing tests pass: npm run test
- [ ] No lint warnings: npm run lint
- [ ] TypeScript compiles: npm run build

Each criterion is a test case. Not "the endpoint should work correctly." That's untestable. Instead: this specific request returns this specific status code. The validation step in the NightLoop pipeline uses these criteria to verify the implementation. If they're vague, validation passes tasks that shouldn't pass. If they're specific, validation catches real bugs.

I always include the existing test and lint commands. Agents sometimes break things that already work. A simple "existing tests still pass" catches regressions before I see them.

Out of scope: the most underrated section

### Out of Scope
- Profile photo upload (separate task #34)
- Email change with verification (separate task #35)
- Admin ability to edit other users' profiles

Without this section, agents wander. I once had a task to "add user settings" and the agent built a full preferences page with theme switching, notification preferences, and data export. I wanted a single endpoint to update two fields.

Agents are completionists. They see a concept like "user settings" and their training data lights up with every settings page ever built. The out of scope section is a fence. It tells the agent: you can see those things, but don't touch them.

I reference other task numbers when I can. It tells the agent those features are planned but handled elsewhere. Reduces the urge to be helpful.

Notes: the catch-all that saves you

### Notes
- Follow the validation pattern in src/lib/validators/auth.ts
  (use zod schemas, not manual checks)
- Return errors in the format: { error: string, fields?: Record<string, string> }
- The user object in responses should exclude passwordHash
  (use the sanitizeUser function from src/lib/users.ts)

This is where project-specific knowledge goes. The patterns you want followed. The libraries you want used. The helper functions that already exist and should be imported, not reinvented.

I keep this section short. If it's growing past 5-6 bullet points, I'm usually missing context that should be in the Context section instead.

The template in practice

Here's a real example from last week. I needed to add a webhook retry system to a project (similar to the pattern discussed in pipeline validation workflows). To see this structure in action across Zowl:

## Task: Add exponential backoff retry for failed webhooks

### Objective
Retry failed webhook deliveries with exponential backoff,
max 5 attempts over 24 hours.

### Context
- Node.js 20, Fastify, TypeScript
- Webhook dispatch at src/services/webhooks/dispatch.ts
- Failed deliveries logged in webhook_events table
  (status column: 'sent' | 'failed')
- BullMQ already configured at src/lib/queue.ts

### Requirements
1. Create a retry queue using existing BullMQ setup
   - Queue name: "webhook-retry"
   - Backoff: exponential, delays [1m, 5m, 30m, 2h, 12h]
2. When dispatch.ts gets a non-2xx response, add to retry queue
3. On max retries exceeded, update webhook_events.status to 'abandoned'
4. Log each retry attempt to webhook_events_log table
   (columns: event_id, attempt, status_code, timestamp)

### Acceptance Criteria
- [ ] Failed webhook is retried after ~1 minute
- [ ] 5th failure marks event as 'abandoned'
- [ ] Retry attempts logged to webhook_events_log
- [ ] Existing webhook tests pass: npm run test
- [ ] No new lint warnings

### Out of Scope
- Webhook event UI dashboard (task #42)
- Manual retry button in admin (task #43)
- Webhook signature verification (already implemented)

### Notes
- Use the existing BullMQ connection, don't create a new one
- Follow the queue pattern in src/services/emails/queue.ts

That took me about 8 minutes to write. The agent nailed it on the first run. No failures. No rework. Eight minutes of writing saved me from a 7 AM debugging session.

Why simple works

I've tried fancier templates. Templates with architecture diagrams, templates with user stories, templates with technical specifications that ran to three pages. They all performed worse.

The reason is straightforward. Agents parse structure well but get lost in volume. A focused 30-line PRD with clear sections outperforms a 200-line document every time. The agent finds what it needs faster, hallucinates less, and sticks closer to the actual requirements.

Six sections. One sentence objective. Specific requirements. Testable criteria. Explicit boundaries. That's the template. It works for a two-line utility function and it works for a complex multi-file feature. I've used it for both, and my overnight success rate sits consistently above 85%.

Keep it simple. Keep it specific. The agents will handle the rest.