A few months ago I wrote about vibe coding and how what looks like magic in a demo turns into debt when it's time to go to production. This article is the natural continuation of that reflection, because since then I've changed my way of working with Claude Code, and that change has a name: spec-driven development.

The idea is not new. Thinking before building is the same discipline as always. What has changed is the context. When coding was expensive, the friction itself forced you to think. Now you ask Claude Code to "make me a notification service" and in twenty minutes you have two thousand lines running. Running for the happy path that the model imagined, not for the one your product needs. I've experienced it several times: beautiful prototypes that had to be thrown away because the AI made twenty architectural decisions no one asked it to make.

The turning point for me was when I stopped treating Claude Code as a giant autocomplete and started treating it like what it is: a very fast and very literal engineer that needs a decent briefing. That briefing is the spec.

What a Spec Is (and What It Isn't)

A spec is a markdown document that lives in the repository and defines three things: what is built, with what constraints, and how we will know it is right. It is the contract between you and the agent. Before asking for code, you write the contract. Afterwards, the prompt is reduced to something as simple as "implement specs/notification-service.md, start with the dispatcher".

It is worth clarifying what it isn't, because this is where I've seen most people stumble. It is not documentation written after the fact to justify what already exists. Nor is it a forty-page PRD: a good spec for an average feature takes between 50 and 150 lines. And it is not a long prompt. The prompt is ephemeral, lost when closing the session. The spec persists, is versioned via PR, and is refined between iterations, just like code.

My rule of thumb: if I delete the entire implementation tomorrow, the spec must allow me to regenerate it without losing any important decisions. The code can be regenerated. The criteria cannot.

Configuring the Context: Rules and Skills

A good spec does not travel alone. For the AI agent to truly understand your development ecosystem, you need to define the project's global rules (rules) and the specific abilities or scripts it can execute (skills). This is configured at the repository level using files like AGENTS.md or the customizations folder. This way, Claude Code or Antigravity know your project's constraints before processing the file.

If you want to delve deeper into how this rule system works, how to load them, and how to prevent the agent from acting on its own, I recommend reading my previous article: How to Get More Out of Google Antigravity (Without the Agent Working Against You), where I detail the use of customizations, rules, and workflows.

The Five Blocks of a Spec That Works

After months of iterating at Venturest, my specs have converged into a five-block structure.

The first is the context and objective: what business problem it solves, in two paragraphs at most. The model needs the 'why' to properly resolve the ambiguities that will inevitably remain.

The second is the scope and out-of-scope. Out-of-scope is the most profitable block in the entire document. It is where you strip the agent of the freedom to "improve" things no one asked for. Each line of out-of-scope translates to hundreds of lines of code you won't have to review or delete.

The third is closed technical decisions: stack, services, patterns, naming conventions. Everything that has already been decided goes here, written as a fait accompli. If you write it as a suggestion, the model will treat it as negotiable.

The fourth is the expected behavior: use cases, edge cases, and errors, in a table or Given/When/Then format. This block is what Claude Code converts almost literally into tests, so the effort pays off twice.

And the fifth, the acceptance criteria: the checklist that allows answering yes or no to the question "is it done?". If a criterion cannot be verified, it is not a criterion, it is an intention.

Claude Code executing a development spec in the terminal.

Real Example: Venturest's Notification Service

When we built our multi-channel notification service, the first request was vague and the result was as expected: a monolith coupled to the backend. The second time I wrote this (abbreviated version):

# SPEC: Notification Service

## Context
Venturest needs to notify platform events (proposals, meetings,
clarifications, payments) via email, push, and in-app. Today emails are sent
inline from the backend, without retries or traceability.

## Scope
- Decoupled service from the backend, event-driven.
- Channels: email (SES), push (SNS + FCM/APNs), in-app (WebSocket API GW).

## Out-of-scope
- NO SMS. NO user preferences in this phase. NO admin panel.

## Technical Decisions (closed, do not propose alternatives)
- Queue: SQS FIFO as input bus. DLQ after 3 retries.
- Processing: Node.js 20 Lambda, batch size 10, 256MB, timeout 15s.
- Templates: Handlebars in S3 (`templates/{type}/{channel}.html|txt`),
  cached in memory in warm starts. Update without redeploy.
- History: DynamoDB with 90-day TTL.
- Origins: backend, EventBridge and external webhooks publish to the
  same queue with a common envelope.

## Expected Behavior
| Case | Result |
|---|---|
| Valid event, email channel | Render template + send SES + register in DynamoDB |
| Non-existent template in S3 | Error log + message to DLQ, no retry |
| SES transient failure | Retry via SQS (max 3), then DLQ |
| Duplicate event (same dedup ID) | Discarded by FIFO, no effect |

## Acceptance Criteria
- [ ] A published event in the queue generates a notification in < 30s (p95)
- [ ] The 3 channels share the same input envelope
- [ ] Dispatcher test coverage > 80%
- [ ] Changing a template in S3 does not require redeploy

With this spec, Claude Code produced the infrastructure, dispatcher, and tests in one session. And most importantly: without inventing an SMS channel or a preferences CRUD that nobody asked for. The out-of-scope block did its job.

Second Example: Meetings, When a Third Party Is in the Equation

The second case seems illustrative to me for another reason: the feature depends on an external provider. We built Venturest Meetings, the integrated video calls within the context of each project, on top of daily.co. When you integrate a third party, the most important task of the spec is to fix the boundary between your product and the provider, because that is precisely the zone where AI improvises most cheerfully.

# SPEC: Venturest Meetings (daily.co)

## Context
Clients and Growth Partners hold project meetings on Zoom or Meet,
outside the platform. The context is lost: what is discussed does not remain linked
to the project nor feed the AI Summaries. Meetings brings the video call
inside the project.

## Scope
- Create and join video calls from the project view.
- Recording and transcription of the session.
- Upon completion, summary pipeline: summary, next steps, and scope alerts
  linked to the project.

## Out-of-scope
- NO webinars or rooms with more than 10 participants. NO native mobile SDK
  in this phase. NO custom chat inside the call (Clarifications is used instead).

## Technical Decisions (closed, do not propose alternatives)
- Provider: daily.co with Prebuilt embedded (iframe). Do not build a custom
  call UI.
- Rooms are created from the backend via daily.co REST API, always
  private, associated with a `project_id`.
- Access only with signed meeting token, generated by our backend,
  with expiration. Never public room URLs.
- daily.co webhook `recording.ready` triggers the transcription
  and summary pipeline. The recording is copied to our S3;
  we are not dependent on the provider's retention policy.
- Tracking: `Meeting Scheduled` event with `account_id`, `project_id`,
  `duration_min`, `source`.

## Expected Behavior
| Case | Result |
|---|---|
| User with project access | Token generated, enters the room |
| User without project access | 403, no token, no room URL |
| Expired token | Access denied, option to regenerate from project |
| Webhook recording.ready | Copy to S3 + transcription + summary in the project |
| daily.co down when creating room | Controlled error to user, manual retry |

## Acceptance Criteria
- [ ] No room is accessible without a token signed by our backend
- [ ] Room created and user inside in < 3s (p95)
- [ ] The summary appears linked to the project after the session
- [ ] Webhook failure does not lose recordings (daily reconciliation)

Look at the access decision. Without a spec, the model would likely generate rooms with public URLs, because that is the path of least resistance and what is shown in the documentation examples of any provider. It is the type of hole that doesn't show up in the demo and appears months later, when someone shares a link where they shouldn't. The spec turns that decision into an explicit rule before a single line of code exists.

Common Mistakes When Writing Specs

The first is writing specs that were too open. "Use best practices" is not an instruction, it is a prayer. Every decision you don't close, the model closes for you, and not always in the direction that suits you.

The second is not versioning the specs. When the feature evolves and the spec falls behind, the agent works against an obsolete contract. The spec lives in the repo and changes via PR, just like any other file.

The third is leaving the border with third parties undefined. When the feature integrates an external provider, as happens with daily.co in Meetings, everything you don't pin down in the spec will be resolved by the model copying the quickstart from the provider's docs. And the quickstart is designed for the demo, not for your permission model or data retention.

And the fourth, perhaps the most subtle, is confusing spec with prompt. The prompt is today's conversation. The spec is the document that outlives the conversation. The prompt points, the spec defines.

Underlying Reflection

There is something almost ironic about all this. AI has brought back a discipline that the industry had been eroding for years. Writing specifications was seen as bureaucracy of another era, and now it turns out to be the lever that separates teams that generate code from those that deliver product.

My conclusion after these months is that the work of the senior engineer has not been reduced, it has shifted. Less time typing implementation and more time doing the work that was always the hard part: deciding what to build, what not to build, and how to know if it is right. AI doesn't take away that work. It lays it out in front of you, in markdown, before the first line of code.

And in your team? Do you write the spec before opening Claude Code, or do you continue negotiating with the model based on prompts? I'm interested to know how you are solving it.

Paulo Bischof
Paulo Bischof
CTO · Product Manager · Software Developer
Let's talk