The Petstore Walkthrough¶
Trace from a 20-line recipe to a fully automated, self-cleaning API test.
This walkthrough takes you through every file in the Petstore example and explains how they compose into working tests. By the end, you will understand how AAT models an API, wires data between steps, runs cleanup automatically, and keeps test plans short.
Prerequisites: Go 1.25+ and Node.js (make build also builds the web UI), internet access (the Petstore API is public). Without Node, make cli builds everything this walkthrough uses except aat web view.
Time: ~15 minutes to read in full. Pass 1 is 2 minutes if you just want to run something.
If you would rather scaffold your own project from an OpenAPI spec, skip to the Quickstart instead.
Pass 1: Run It and See¶
Build and run¶
From the repository root:
make build
cd examples/petstore
../../aat run plan plans/create-and-verify.yaml
You should see something like:
[1/2] createPet 200 164ms
[2/2] getPet 200 38ms
cleanup:
deletePet 200 40ms
PASSED (2/2 steps, 243ms)
Archive: _output/runs/run-XXXXXXXX-XXXXXX-XXXXXXXX/archive.json
That is the entire test. Two API calls, both passed, and a cleanup step deleted the pet afterward.
Here is the thing: you did not write cleanup logic. You did not wire the pet ID between steps. You did not pick a pet name. AAT did all of that. The recipe that drove this run is 20 lines of YAML, and most of those are assertions.
Run the second plan¶
../../aat run plan plans/find-and-verify.yaml
This one searches for pets by status, picks the first result from the array, and retrieves it by ID. Array selection, driven by three lines in a workflow template.
Run everything as a batch¶
../../aat run batch
This discovers and runs every plan in the plans/ directory. Add --json for CI pipelines (it implies --quiet).
Inspect the results¶
Every run writes a JSON archive to the _output/runs/ directory (configured by archives in the manifest). You can browse the full request/response details in the web UI:
../../aat web view latest
This starts a local web server (stop it with Ctrl+C) and opens the most recent run in your browser with a Gantt timeline of each step, request and response bodies, headers, and assertion results. Without latest, it opens the list of runs.
What you are about to learn¶
The rest of this walkthrough traces through five layers that made those runs work:
- The manifest tells AAT where everything lives.
- The environment provides the base URL and auth.
- The graph models the API: operations, data types, defaults, data flow, and cleanup.
- Templates translate between the graph's semantic names and actual HTTP requests.
- Workflows and recipes compose the above into executable tests.
Each layer does one job and delegates the rest. That is how a 20-line recipe produces a self-cleaning, data-wired, multi-step API test.
The Project Manifest (aat-project.yaml)¶
name: petstore
description: Swagger Petstore API quickstart example
tags: [quickstart, petstore]
graph: graph.yaml
templates: templates/
domain: domain.yaml
environment: env.yaml
workflows: workflows/
plans: plans/
archives: _output/runs
The manifest marks the project root and tells AAT where to find every artifact. When you run a command from this directory (or any subdirectory), AAT walks up the file tree until it finds aat-project.yaml and resolves all paths relative to it.
This is why ../../aat run plan plans/create-and-verify.yaml works without --env-config, --graph, or --templates flags. The manifest supplies all of them.
Each field maps to one file or directory:
| Field | Points to | Purpose |
|---|---|---|
graph |
graph.yaml |
The API operation model |
templates |
templates/ |
HTTP request/response definitions |
domain |
domain.yaml |
Optional domain knowledge (value pools, concepts) |
environment |
env.yaml |
Base URL, auth, settings |
workflows |
workflows/ |
Reusable plan skeletons |
plans |
plans/ |
Concrete test recipes |
archives |
_output/runs |
Where run results are written |
Further reading: Project Setup
The Environment (env.yaml)¶
environment: petstore
apiBaseUrl: https://petstore.swagger.io/v2
auth:
type: none
The environment file is the only file that changes between environments (dev, staging, production). It provides:
apiBaseUrl— prepended to every template's request path.auth— authentication configuration. The Petstore API is public, sotype: none. Real APIs would useoauth2,apikey, orbearerhere.
Everything else (the graph, templates, workflows, recipes) stays the same across environments.
Further reading: Environments
The Graph (graph.yaml) — The Heart¶
The graph is where the real work happens. It models your API as a set of nodes (operations) with typed inputs and outputs, default values, data-flow wiring, and cleanup declarations. Here is the full file, then we will walk through each node.
version: "1.0.0"
oas: petstore-spec.yaml
nodes:
createPet:
description: "Create a new pet in the store"
adapter: createPet
oas:
operationId: addPet
inputs:
- name: name
type: string
description: "Name of the pet"
default:
pool: [Buddy, Max, Bella, Charlie, Luna, Cooper, Daisy, Rocky]
- name: status
type: enum[available, pending, sold]
description: "Availability status"
optional: true
default: available
outputs:
- name: petId
type: integer
description: "Assigned pet ID"
- name: petName
type: string
description: "Pet name as stored"
- name: petStatus
type: string
description: "Pet status as stored"
cleanup: deletePet
getPet:
description: "Retrieve a pet by its ID"
adapter: getPet
oas:
operationId: getPetById
inputs:
- name: petId
type: integer
description: "Pet ID to look up"
default:
from: createPet.petId
outputs:
- name: petId
type: integer
description: "Pet ID"
- name: petName
type: string
description: "Pet name"
- name: petStatus
type: string
description: "Pet status"
findByStatus:
description: "Find pets by their availability status"
adapter: findByStatus
oas:
operationId: findPetsByStatus
inputs:
- name: status
type: enum[available, pending, sold]
description: "Status to filter by"
default: available
outputs:
- name: pets
type: pet[]
description: "List of matching pets"
elementFields:
- name: petId
type: integer
- name: petName
type: string
- name: petStatus
type: string
deletePet:
description: "Delete a pet from the store"
adapter: deletePet
oas:
operationId: deletePet
inputs:
- name: petId
type: integer
description: "Pet ID to delete"
default:
from: createPet.petId
outputs: []
workflows:
- name: Create and Verify
description: "Create a pet and verify it was stored correctly"
template: workflows/create-and-verify.yaml
- name: Search and Retrieve
description: "Find pets by status and retrieve one by ID"
template: workflows/search-and-retrieve.yaml
That is 98 lines. Let's break it down.
createPet — defaults and cleanup¶
createPet:
inputs:
- name: name
type: string
default:
pool: [Buddy, Max, Bella, Charlie, Luna, Cooper, Daisy, Rocky]
- name: status
type: enum[available, pending, sold]
optional: true
default: available
outputs:
- name: petId
type: integer
- name: petName
type: string
- name: petStatus
type: string
cleanup: deletePet
Two things to notice:
Default values. The name input has a pool default — AAT picks a random name from the list each run. The status input has a literal default of "available". Neither the workflow nor the recipe needs to specify these values. They only override when they want something specific.
Cleanup declaration. cleanup: deletePet tells AAT: "whenever a plan creates a pet with createPet, schedule a deletePet call after the plan finishes, even if a later step fails." The cleanup step's own petId input resolves automatically: cleanup inputs are matched by name against the outputs of the steps that ran, and createPet has a petId output. You declare cleanup once in the graph, and every plan that creates a pet inherits it for free.
getPet — data flow¶
getPet:
inputs:
- name: petId
type: integer
default:
from: createPet.petId
The from default says: "if no one provides a petId, take it from createPet's petId output." This is data-flow wiring at the graph level. When a plan has a createPet step followed by a getPet step, the pet ID flows between them automatically.
The recipe does not mention petId at all. The workflow does not mention it either. The graph handles it.
findByStatus — arrays and element fields¶
findByStatus:
outputs:
- name: pets
type: pet[]
elementFields:
- name: petId
type: integer
- name: petName
type: string
- name: petStatus
type: string
The type: pet[] marks this output as an array. The elementFields describe the shape of each element in that array — what fields exist and what types they have.
This metadata is what makes selection strategies possible. When a workflow says "take the first pet from this list and use its petId," AAT knows how to reach into each array element and extract the right field. You will see this in action in the Search and Retrieve workflow.
deletePet — cleanup target¶
deletePet:
inputs:
- name: petId
type: integer
default:
from: createPet.petId
outputs: []
Simple node, but notice the default: {from: createPet.petId} — same wiring as getPet. That default applies when a plan runs deletePet as a regular step. As a cleanup step, deletePet does not use it: its petId input is matched by name to the petId output of a step that ran, which here is createPet's. Either way, no explicit wiring is needed in the recipe.
Workflows section¶
workflows:
- name: Create and Verify
description: "Create a pet and verify it was stored correctly"
template: workflows/create-and-verify.yaml
- name: Search and Retrieve
description: "Find pets by status and retrieve one by ID"
template: workflows/search-and-retrieve.yaml
This registers two workflows by name. Recipes reference them by name (e.g., workflow: Create and Verify), and AAT loads the corresponding template file. The names are for humans; the template paths are for AAT.
Further reading: API Graphs, Value Resolution
Templates (templates/*.yaml)¶
Templates are the translation layer between the graph's semantic names and actual HTTP requests. Each node in the graph has a matching template that defines the method, path, headers, body, and response extraction rules.
createPet.yaml — POST with extraction¶
adapter: createPet
protocol: http
request:
method: POST
path: /pet
headers:
Content-Type: application/json
Accept: application/json
body: |
{
"name": "{{name}}",
"status": "{{status}}",
"photoUrls": []
}
response:
extract:
petId: "id"
petName: "name"
petStatus: "status"
The {{name}} and {{status}} placeholders correspond to the graph node's input names. At execution time, AAT resolves those inputs (from defaults, overrides, or references) and substitutes them into the template.
The response.extract section maps API response field names to graph output names. The Petstore API returns "id" in its JSON; the graph calls it petId. This renaming happens here, so the rest of the system works with consistent semantic names regardless of the API's raw field names.
The path is relative — AAT prepends the environment's apiBaseUrl to produce https://petstore.swagger.io/v2/pet.
getPet.yaml — path parameter¶
adapter: getPet
protocol: http
request:
method: GET
path: /pet/{{petId}}
headers:
Accept: application/json
response:
extract:
petId: "id"
petName: "name"
petStatus: "status"
The {{petId}} placeholder appears directly in the URL path. AAT substitutes the resolved value (e.g., /pet/12345). Same extraction rules as createPet — the API returns the same shape.
findByStatus.yaml — array extraction¶
adapter: findByStatus
protocol: http
request:
method: GET
path: /pet/findByStatus?status={{status}}
headers:
Accept: application/json
response:
extract:
pets:
path: "$"
fields:
petId: "id"
petName: "name"
petStatus: "status"
This template returns an array. The path: "$" means "the entire response body is the array" (the Petstore API returns a top-level JSON array). The fields block maps each element's API field names to the graph's elementField names — same renaming as before, but applied to every element in the array.
After extraction, the pets output contains a structured array where each element has petId, petName, and petStatus fields. Selection strategies can then pick elements from this array by those names.
deletePet.yaml — minimal¶
adapter: deletePet
protocol: http
request:
method: DELETE
path: /pet/{{petId}}
headers:
Accept: application/json
response: {}
No extraction — the delete endpoint does not return useful data. The empty response: {} tells AAT there is nothing to capture.
Further reading: Templates
Workflows (workflows/*.yaml)¶
A workflow is a reusable plan skeleton. It defines what steps run, in what order, with what dependencies. It delegates everything else — input values, cleanup, assertions — to the graph and the recipe.
create-and-verify.yaml — two steps¶
intent:
description: "Create a pet and verify it was stored correctly"
execution:
steps:
- node: createPet
description: "Create a new pet"
- node: getPet
dependsOn: [createPet]
isGoal: true
description: "Verify the pet exists with correct data"
Twelve lines. The workflow says:
- Run
createPet. - Then run
getPet(it depends oncreatePet). getPetis the goal step — the one whose success means the test passed.
Notice what the workflow does not say:
- What name to use for the pet. (The graph's pool default handles it.)
- What status to use. (The graph's literal default handles it.)
- How to pass the pet ID from step 1 to step 2. (The graph's
fromdefault handles it.) - What to clean up afterward. (The graph's
cleanup: deletePethandles it.)
The workflow only defines structure. Everything else is inherited.
search-and-retrieve.yaml — array selection¶
intent:
description: "Find pets by status and retrieve one by ID"
execution:
steps:
- node: findByStatus
description: "Search for pets by status"
- node: getPet
dependsOn: [findByStatus]
isGoal: true
description: "Retrieve the first matching pet by ID"
selections:
pet:
from: findByStatus.pets
strategy: first
values:
petId:
fromSelection: pet.petId
This workflow introduces selections. The getPet step needs a petId, but there is no createPet to provide one. Instead, it selects from the array returned by findByStatus:
selections.pet— defines a selection namedpet. It takes fromfindByStatus.pets(the array output) usingstrategy: first(pick the first element).values.petId— setspetIdtopet.petId, which is thepetIdelement field from the selected array element.
Three lines turn "search returns many results" into "use one specific result's ID." The selection strategies available (first, last, index, min, max, match, random) cover most real-world scenarios, and they are declared in the workflow, not coded in a script.
Further reading: Workflows, Value Resolution
Recipes (plans/*.yaml) — The Payoff¶
A recipe is what pulls a workflow together into a specific test. It selects a workflow, then customizes it: overriding input values, adding assertions, enabling optional features, or exercising atypical paths. The Petstore recipes are simple (just assertions), but recipes in larger projects can activate addons to splice extra steps into a workflow, fill slots to choose between workflow variants, or apply layers to swap in alternate test data — all without duplicating the workflow itself. The recipe is the minimal representation of what you want to test.
create-and-verify.yaml¶
kind: recipe
metadata:
created: 2026-02-11T12:00:00Z
prompt: "Create a pet and verify it exists"
graphVersion: "1.0.0"
selection:
workflow: Create and Verify
description: "Create a pet using pool defaults and verify it was stored correctly"
overrides:
assertions:
createPet:
- type: status
expect: 200
- type: fieldExists
path: petId
getPet:
- type: status
expect: 200
- type: fieldExists
path: petName
Twenty lines. This is the entire test.
The selection block picks the "Create and Verify" workflow. The overrides block adds assertions: verify that createPet returns HTTP 200 and produced a petId, and that getPet returns 200 and produced a petName. fieldExists checks the step's extracted outputs, not the raw response body, which is why the paths are petId and petName rather than the API's id and name (add raw: true to check the body instead).
That is it. No step definitions, no input values, no data wiring, no cleanup logic. The workflow provides the step structure. The graph provides input defaults, data flow, and cleanup. The recipe customizes the workflow for this particular test — here, by adding assertions. In a larger project, the same workflow could back dozens of recipes, each exercising a different scenario.
find-and-verify.yaml¶
kind: recipe
metadata:
created: 2026-02-11T12:00:00Z
prompt: "Find pets by status and retrieve one"
graphVersion: "1.0.0"
selection:
workflow: Search and Retrieve
description: "Search for available pets and retrieve the first match by ID"
overrides:
assertions:
findByStatus:
- type: status
expect: 200
getPet:
- type: status
expect: 200
- type: fieldExists
path: petName
Even the array selection complexity is invisible at the recipe level. The workflow handles the selection logic. The recipe customizes to this test's needs.
These Petstore recipes only use assertion overrides, but the recipe format supports much more: addons that splice extra steps into a workflow, slots that choose between workflow variants, and layers that swap in alternate test data sets. All of these compose at runtime without duplicating the underlying workflow.
Further reading: Plans and Recipes — covers addons, slots, layers, and the full override syntax
Seeing It All Together¶
Here is what happens when you run ../../aat run plan plans/create-and-verify.yaml, traced through every layer:
1. Discovery. AAT finds aat-project.yaml in the current directory and resolves all paths: graph, templates, environment.
2. Recipe expansion. The recipe says workflow: Create and Verify. AAT loads workflows/create-and-verify.yaml and merges the recipe's assertion overrides into the workflow's step structure, producing a full plan with two steps.
3. Input resolution for createPet. The step has two inputs: name and status. Neither the recipe nor the workflow provides values, so AAT falls back to the graph defaults. name picks a random value from the pool (say, "Cooper"). status uses the literal default "available".
4. Template filling for createPet. AAT loads templates/createPet.yaml, substitutes {{name}} with "Cooper" and {{status}} with "available", prepends the base URL, and sends the POST request.
5. Extraction. The API responds with {"id": 12345, "name": "Cooper", "status": "available", ...}. The template's response.extract maps id to petId, name to petName, and status to petStatus. These values enter the execution state.
6. Assertions for createPet. AAT checks: status is 200 (pass), petId field exists (pass).
7. Input resolution for getPet. The step needs petId. The graph default says from: createPet.petId. AAT looks up petId from the createPet step's extracted outputs — it is 12345.
8. Template filling, execution, and assertions for getPet. Same flow: load template, substitute {{petId}} with 12345, send GET to /pet/12345, extract, check assertions.
9. Cleanup. Because createPet declares cleanup: deletePet in the graph, the reconstituted plan lists deletePet in its cleanup section. Now that the plan is done (whether it passed or failed), AAT runs deletePet. Its petId input is matched by name to the petId output that createPet extracted — 12345. The pet is deleted.
Nine things happened, and you configured exactly three of them: the workflow name, and two sets of assertions. The graph defined the operations, defaults, data flow, and cleanup. The templates handled HTTP translation. The workflow defined step ordering. The recipe added the test criteria.
Browsing Results (aat web view)¶
After any run, the full details are preserved in a JSON archive under _output/runs/. You can read the JSON directly, but the web UI is the faster way to make sense of it:
../../aat web view latest
This opens the most recent run in your browser. Here is what you get:
A Gantt timeline of the entire run. Every step is a bar on the timeline — you can see at a glance how long each step took and where the time went. Cleanup steps appear separately so you can distinguish test logic from teardown.
Full request and response details for every step. Click a step and you see the HTTP method, URL, headers, request body, response status, response body, and extracted outputs. No detail is lost. If a test fails next week because the API changed a field name, you do not have to reproduce the failure — the archive already has the exact response that caused it.
Assertion results. Each step shows which assertions passed and which failed, with the expected and actual values side by side.
Batch runs. When you run aat run batch, the web UI shows every plan in the batch with the same level of detail. You can drill from the batch summary into any individual run and from there into any step.
Why this matters¶
The archive is a self-contained record of what happened. You can send the archive directory to a teammate, attach it to a Jira ticket, or store it as a CI artifact. Anyone who opens it with aat web view <path> sees exactly what the API returned — not a screenshot, not a summary, not someone's recollection of what went wrong. The raw facts, every body and every header except the redacted auth headers.
No more screenshots and handwaving. The archive gets right to the evidence. Because bodies are stored as-is, check what an archive from a real API contains before you share it outside your team.
What You Can Do Next¶
-
Validate the project:
aat validatechecks graphs, templates, plans, and workflows for consistency. See Validation. -
Run in CI/CD: Add
--jsonto get machine-readable output and clean exit codes. Archives make great pipeline artifacts. See CI/CD Integration. -
Build your own project: The Quickstart scaffolds a graph from an OpenAPI spec in 5 minutes. The Tutorial builds everything from scratch.
-
Let an AI assistant write tests:
aat mcp servegives Claude Code and other MCP clients the graph and the tools to compose, validate, and run plans. See MCP Server. For a one-shot draft from a sentence, there is alsoaat prompt.