You can build an API with AI in an afternoon: ask a coding agent like Claude Code or Codex for “a REST API for tasks with users” and you’ll get routes, a database and a running server. Whether that API survives its first real users is another question. Unvalidated input, endpoints that hand out other people’s data and migrations nobody read are the usual problems. This post walks through how I build a REST backend with agents, from stack choice to deploy, and where to slow down.
It’s part of the series that started with building a SaaS with AI. The SaaS post covers the product; this one covers the backend underneath it, for a mobile app, a frontend or other services to call. If you’re new to working this way, how to vibe code is a good warm-up.
1. Define the API before any code
An API is a contract. Once a mobile app ships against it, changing a field name is expensive. So before the agent writes code, write down:
- Resources: the nouns. For example
users,projects,tasks,comments. - Endpoints:
GET /v1/projects,POST /v1/projects,GET /v1/projects/:id/tasks,PATCH /v1/tasks/:idand so on. - Who can do what: “Members of a project can read its tasks; only the owner can delete the project.”
- Error format: one shape for every error, with an HTTP status, a machine-readable code and a message.
- Pagination: cursor or page, default and maximum page size.
Keep the first version to the endpoints a real client needs. An admin API, webhooks and bulk import can wait.
2. Put the rules in a spec file
Agents don’t remember earlier conversations. Claude Code reads CLAUDE.md at the start of every session; Codex and many other agents read AGENTS.md. Put your decisions there:
# Tasks API
## Stack
- Node.js LTS, TypeScript, Fastify
- PostgreSQL, Prisma for schema and migrations
- Zod for request validation; OpenAPI generated from the same schemas
- Vitest; tests use app.inject() against a test database
## Rules
- All routes under /v1. JSON only.
- Every request body, query and param is validated with a schema.
- Errors: { "error": { "code": "...", "message": "..." } }, never a stack trace.
- Every query on tasks and projects checks project membership.
- Never return password hashes or tokens; use explicit response schemas.
- Database changes only through migration files.
- List endpoints: cursor pagination, default 20, max 100.
- Secrets come from environment variables; never in code or logs.
- Ask before adding a dependency.
- After every task: pnpm lint && pnpm test
## Out of scope (for now)
Admin API, file uploads, webhooks, GraphQL.
Each rule here exists because agents get it wrong without it. “Explicit response schemas” stops the agent from returning the whole user row, password hash included. “Max 100” stops someone from asking for a million rows. More on writing this file in CLAUDE.md and AGENTS.md.
3. Pick a boring stack
The agent writes better code on a stack it has seen thousands of times. Two safe choices:
| TypeScript | Python | |
|---|---|---|
| Framework | Fastify (or Express, Hono) | FastAPI |
| Validation | Zod or JSON Schema | Pydantic |
| Database access | Prisma or Drizzle | SQLAlchemy |
| Migrations | Prisma Migrate, Drizzle Kit | Alembic |
| OpenAPI | @fastify/swagger | Built in |
| Tests | Vitest + app.inject() | pytest + TestClient |
Both work. Pick the language you can read, because you will be reading a lot of diffs. I’ll use TypeScript, Fastify, Prisma and PostgreSQL in the examples.
Run Postgres locally with Docker so the agent never needs a shared database:
docker run --name tasks-db -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16
mkdir tasks-api && cd tasks-api && git init
4. Plan first, then one resource at a time
My first prompt asks for a plan, not code:
Read CLAUDE.md. Don't write code yet.
Propose:
1. The Prisma schema (models, relations, indexes, unique constraints)
2. The full endpoint list with request and response shapes
3. How authorization works for each endpoint
4. 6–8 implementation steps, each ending with tests that pass
List open questions at the end.
Fix the plan, answer the questions, update the spec. Then go step by step:
Implement step 2: the projects resource (list, create, get, update,
delete). Validate input with Zod, use explicit response schemas,
check membership on every query. Write tests for the happy path,
invalid input, and a second user who is not a member. Run the tests,
show me the result, then stop.
“Then stop” is there so I can read the diff and commit before the next step. When the agent drifts, I go back one commit instead of untangling five resources.
5. Schema and migrations
The schema is the hardest part to change later, so read it carefully:
- Constraints in the database, not only in code.
NOT NULL, unique emails, foreign keys with a deliberateON DELETEbehavior. - Indexes for the queries you run. Every foreign key you filter on and every column you sort by in a list endpoint.
- Timestamps (
createdAt,updatedAt) on every table; they help with debugging and cursor pagination. - Money as integers or decimals, never floating point.
Every change goes through a migration:
npx prisma migrate dev --name add_tasks # create and apply locally
npx prisma migrate reset # rebuild the dev database from zero
npx prisma migrate deploy # apply pending migrations in production
Once there’s real data, read every migration file the agent writes. Stop and ask about DROP COLUMN, DROP TABLE, renames (Prisma can turn them into drop and add), UPDATE or DELETE without WHERE, and new NOT NULL columns on big tables without a default.
6. Authentication: use something that exists
Don’t have the agent invent password storage or token formats. Use a hosted auth provider or a well-known library, and keep the agent’s job to wiring it in:
- Passwords hashed with argon2 or bcrypt through a maintained library, never stored or logged in plain text.
- Sessions or tokens: for a browser client on the same site, an
HttpOnly,Secure,SameSitesession cookie is the simplest safe choice. For mobile or third-party clients, short-lived access tokens with refresh tokens. - Verify tokens properly. Signature, expiry, issuer and audience; reject the
nonealgorithm. Ask the agent to show you exactly where this happens. - Rate-limit login and password reset so nobody can try thousands of passwords.
7. Authorization: the bug agents write most
Authentication answers “who is this?” Authorization answers “may they touch this record?” Agent-written APIs usually get the first right and the second wrong. The classic version is GET /v1/tasks/:id that checks a valid token, then loads the task by ID. Any logged-in user can read any task by changing the number. OWASP lists this, broken object level authorization, first in its API Security Top 10.
The fix is a habit, not a library:
- Every query that loads a record by ID also filters by what the caller may access:
where: { id, project: { members: { some: { userId } } } }. - Not found and not yours return the same
404, so IDs can’t be probed. - Test it with two users on every endpoint that takes an ID. These tests are the most valuable ones in the project.
A related mistake is mass assignment: the agent passes the request body straight into update(), and a user sends "role": "admin" or "ownerId": "...". Validation schemas that list only the allowed fields stop this.
Ask the agent: “List every endpoint and the authorization check it performs. Mark any endpoint that loads a record by ID without filtering by the caller.”
8. Validation and errors
Every input crossing the boundary is validated: body, query string, path params, headers you use. With Zod and Fastify the route declares its schemas, and invalid requests are rejected with a 400 before your code runs.
- Reject unknown fields instead of silently ignoring them; it surfaces client bugs early.
- Limit sizes: string lengths, array lengths, request body size, page size.
- One error shape for everything, and a global error handler that logs the details server-side but returns only
codeandmessageto the client. Stack traces and SQL errors in responses give attackers a map. - Parameterized queries only. The ORM does this by default; watch for raw SQL helpers with string concatenation, such as Prisma’s
$queryRawUnsafe.
9. Tests that matter
Fastify’s app.inject() sends requests to the app without opening a port, so API tests are fast. You don’t need 100% coverage; you need these:
- Each endpoint’s happy path, checking status code and response shape.
- Invalid input returns
400with your error format. - No token returns
401; someone else’s record returns404. - Pagination honors the maximum.
- Migrations apply from zero on a clean database.
Tests run against a separate test database that is reset between runs. Never let the test suite near production credentials.
Watch for the agent “fixing” a failing test by changing the assertion. If a test file shows up in a diff you didn’t ask for, ask why. AI code review has a checklist for this.
10. OpenAPI docs from the same schemas
Docs that are written by hand drift. Generate them from the validation schemas so the documentation describes exactly what the code accepts. In Fastify, @fastify/swagger builds an OpenAPI document from your route schemas and @fastify/swagger-ui serves it; FastAPI does this out of the box.
Add @fastify/swagger and @fastify/swagger-ui. Generate the OpenAPI
document from the existing route schemas, add descriptions and
examples, document the error format and auth. Serve the UI at /docs
only when NODE_ENV is not production.
The OpenAPI file is useful beyond docs: frontend agents can generate a typed client from it, and other tools can discover your endpoints from it.
11. Deploy
A small API runs fine as a container on a platform like Fly.io, Railway or Render, or on any server with Docker. A checklist:
- A Dockerfile with a multi-stage build and a non-root user. Ask the agent for one, then read it.
- Migrations as a separate step (
prisma migrate deploy) before the new version starts, not inside every app instance on boot. - Environment variables for the database URL, secrets and allowed CORS origins, set in the platform, not in the image.
- A health endpoint (
/health) the platform can check. - CORS limited to your real frontends; never
*with credentials. - HTTPS only, security headers (for example with
@fastify/helmet), and request logging without passwords, tokens or personal data. - Backups turned on for the production database, and a restore tried at least once.
- Error tracking such as Sentry, so you hear about the
500s before your users tell you.
What the AI gets wrong
- Returning whole database rows. Password hashes, internal flags and other users’ emails in the JSON. Explicit response schemas fix it.
- Inventing APIs. Library functions that don’t exist or changed versions ago. Give the agent current docs, and paste errors back as they are.
- No pagination.
findMany()with no limit works until the table has 200,000 rows. - N+1 queries. A query per item inside a loop. Ask the agent to log queries in development and look for repeats.
- Swallowed errors.
catch {}that returns200anyway. - Secrets in code. A hard-coded JWT secret “just for now” that ships to production.
- Scope creep. “I also added an admin endpoint.” Revert it or move it to its own branch.
Security: short but serious
- Keep
.envin.gitignore; rotate any key that was ever committed. - Don’t paste API keys into the chat; the agent only needs the variable name.
- Point the agent at a local or staging database, never production.
- Read commands the agent runs, especially database commands,
rm, force pushes andcurl … | sh. - Run
pnpm audit(or your package manager’s equivalent) now and then and hand the output to the agent. - Rate-limit public endpoints, not just login.
For more on keeping agents safe on your machine, see AI coding agent security.
When to bring in a human developer
A few hours of review by an experienced backend developer pays for itself:
- Before real users sign up, for authentication and authorization
- When the API stores personal, health or payment data
- When a partner or enterprise customer will integrate against it
- When performance falls over: slow queries, missing indexes, connection limits
- When you and the agent have failed to fix the same bug three times
Make it easy for them: the spec file, the OpenAPI document, the migrations folder and a green test run.
How I do this with AgentVera
All of this works in one terminal. With several agents, AgentVera helps in these places:
- Parallel work in worktrees. In the workspace I run one agent on the API and another on the frontend or the tests, each in its own git worktree so they don’t step on each other’s files. The method is in git worktrees for parallel agents.
- Trying endpoints without another tool. The API client finds the project’s endpoints from OpenAPI files and from routes in Express, Fastify, Hono, Next.js, FastAPI, Django, NestJS and others. I send a request with parameters, headers and a body, and if the response is an error, I hand the request and response to an agent in one click.
- Seeing what landed in the database. The database manager opens local Postgres so I can check the rows the agent created, and DB Guard rates the risk of each migration when the agent ends its turn and can try it on a shadow database.
- Containers. The Docker page shows the Postgres container and its logs next to the agents.
- Reviewing turns. Code review checks every finished turn before I merge.
These speed up the loop; the spec, the tests and your own review are still what make the API safe.
Wrapping up
To build an API with AI, decide the contract first, write the rules into CLAUDE.md or AGENTS.md, pick a mainstream stack, and have the agent build one resource at a time with tests. Validate every input, change the database only through migrations, check ownership on every record, generate OpenAPI from the same schemas, and run migrations as a separate deploy step. Get a human review before real users arrive. If you want to run the backend, frontend and test agents side by side, download AgentVera for free.