Typed client from your swagger
Request bodies, path params, response bodies and statuses come from the spec. The backend renames a field, and the test turns red before it even runs.
Built for AI agents
npm i -D eberlyeberly reads your backend’s OpenAPI spec (JSON or YAML) and generates a typed TypeScript client for tests. Every test user gets their own state, hooks keep tokens and ids for you, and when the backend changes its contract, the tests stop compiling.
One assert checks the status and the body: exact values, schemas (Zod,
Valibot, ArkType) and vitest matchers, all typed by the spec.
const world = new World()const user = world.createUser()
await user.signUp() // register + log inconst body = { title: 'Hello', content: 'First post' }const created = await user.posts.create({ body })
created.assert(201, { // typed status title: 'Hello', // exact value createdAt: z.iso.datetime(), // Zod, Valibot, ArkType… authorId: expect.any(Number), // vitest / Jest matcher})Edit in the playground
Scenario: Alice creates a post, Bob tries to delete it and must get 403.
Before: bare fetch, tokens by hand, any everywhere.
const base = 'http://localhost:3000'const json = { 'Content-Type': 'application/json' }
async function signUp(email: string) { const body = JSON.stringify({ email, password: 'secret123' }) await fetch(`${base}/auth/register`, { method: 'POST', headers: json, body }) const login = await fetch(`${base}/auth/login`, { method: 'POST', headers: json, body }) const { accessToken } = await login.json() // any return { ...json, Authorization: `Bearer ${accessToken}` }}
const alice = await signUp('alice@example.com')const bob = await signUp('bob@example.com')
const created = await fetch(`${base}/posts`, { method: 'POST', headers: alice, body: JSON.stringify({ title: 'Hello', content: 'First post' }),})const post = await created.json() // any: `post.titel` compiles just fineconst res = await fetch(`${base}/posts/${post.id}`, { method: 'DELETE', headers: bob })expect(res.status).toBe(403)expect((await res.json()).message).toBe('Only the author can do this')After: eberly
(tests/readme.test.ts).
const world = new World()const alice = world.createUser()const bob = world.createUser()await alice.signUp({ email: 'alice@example.com', password: 'secret123' })await bob.signUp({ email: 'bob@example.com', password: 'secret123' })
const post = await alice.posts.create({ body: { title: 'Hello', content: 'First post' } })const res = await bob.posts.remove({ path: { postId: post.assert(201).body.id } })res.assert(403, { message: 'Only the author can do this' })signUp is a scenario you write once in your
userStore.ts. A hook
(hooks.ts) adds the Bearer
token to every request of that user. posts.create, posts.remove, the body
and the statuses 201 / 403 are all checked by TypeScript.
An agent needs two things to write tests: the URL of a running backend and its OpenAPI spec. No access to the backend code.
eberly/api/INDEX.md lists the
endpoints. The agent opens one short file per endpoint it calls, not the
whole spec or the generated client.npx eberly create installs Claude Code skills for
setup and test writing.What keeps a human’s head clear keeps an agent’s context small. More in Writing tests with AI agents and Motivation.
The playground runs right here: edit the spec and watch the test above go red where the contract changed. Or upload your own spec: it is processed in your browser and never leaves it.
Want the real thing? Open the NestJS example in StackBlitz: the backend starts in your browser, the client is generated from its spec and the tests go green in a couple of minutes. Edit a test and it reruns.
Open in StackBlitzTyped client from your swagger
Request bodies, path params, response bodies and statuses come from the spec. The backend renames a field, and the test turns red before it even runs.
Many users, each with their own state
world.createUser() gives a user with their own token and variables, so
“Bob can’t touch Alice’s post” is three lines.
Hooks keep tokens and ids for you
A before hook adds the token, an after hook saves the id of the created
entity. Tests don’t pass them around.
Status with autocomplete, partial body check
res.assert(201, { title }) suggests the statuses declared in the spec,
checks only the fields you pass (at any depth) and narrows res.body to
the type of that status.
Transparent token refresh
A globalRetry hook refreshes the token on 401, and eberly replays the
request: the test sees 200.
File upload by path
{ body: { file: './avatar.png' } }, and eberly builds the multipart form.
Privacy-friendly analytics, no cookies.