Tutorial: build a BMI API with Zana and an AI coding agent
Step by step, from an empty project to a working, tested API: the agent inspects the application, proposes a plan you review, generates the structure with make:plan, writes the business logic, and proves it with check and 31 tests.

In this tutorial, you build a small BMI (body mass index) API with Zana and an AI coding agent. The API records a height, a weight and an optional age, calculates the BMI, classifies it, stores it and lists past measurements.
The point is not the BMI formula, which fits on one line. The point is the way of working. The agent reads the project's rules, proposes a plan that you review, generates the structure with Zana's own commands and writes the business logic. Zana's check command and the tests then prove the result before anyone calls it done.
We followed every step below with Claude Code on Zana 2.11, and the outputs shown are the real ones. Any agent that can run commands works the same way: Codex, Copilot, Cursor and others. You can also type each command yourself; nothing here depends on an AI provider.
What you need
- PHP 8.5 with the
pdo_sqliteextension, and Composer. - An AI coding agent that can read files and run commands in your project. This is optional, since every step works by hand too.
- About thirty minutes.
Step 1: Create the project
Start from Zana's API skeleton. It comes with a database, user accounts with bearer tokens, errors in problem JSON, tests, and the instructions for AI agents.
composer create-project mj-zana/skeleton-api bmi-api
cd bmi-api
php bin/console migrate
Look at the files at the root. Two of them are for your agent:
AGENTS.mdholds the rules of a Zana application. It covers the workflow to follow, the layers of a module and what each may depend on, how to register services, what not to do, and the commands to run. It names the installed version (2.11) and links to that version's documentation.CLAUDE.mdcontains one line,@AGENTS.md, and.github/copilot-instructions.mdpoints to the same file. Whichever agent you use finds the same rules.
Before changing anything, confirm that the project is healthy:
php bin/console check
✔ The container builds
✔ Deployment checks: BuiltInChecks
✔ Deployment checks: LogFilesCheck
✔ All 64 singletons can be created
✔ Architecture: 1 module(s), no violation
✔ MiddlewareCheck
✔ RouteHandlersCheck
✔ MigrationsCheck
✔ AgentsCheck
[OK] The application passes its checks.
AgentsCheck confirms that AGENTS.md matches the installed Zana version. If it ever goes stale after an upgrade, check warns you.
Step 2: Describe what you want
Open your agent in the project folder and give it the request in plain words:
Add a BMI module to this API. A client posts a height in centimetres, a weight in kilograms and an optional age. The API calculates the BMI (weight divided by height in metres squared, rounded to one decimal), classifies it, stores the measurement and returns it. Another endpoint lists the most recent measurements, newest first. Follow AGENTS.md. Show me a plan before writing any code.
AGENTS.md tells the agent to restate the request, inspect the application and reuse what exists before adding anything. The last sentence of the prompt makes the review step explicit.
Step 3: Let the agent inspect the application
The agent's first command is:
php bin/console inspect --format=json
inspect lists the modules, services, routes, console commands, migrations, packages and configuration keys. Configuration values are never shown, so your .env secrets stay out of the conversation. Here, the agent sees one module, Accounts, and nothing about BMI. The new feature therefore gets its own module.
You can run it yourself without --format=json for a readable table, or ask for one section: php bin/console inspect routes.
Step 4: Review the plan
Instead of improvising files, the agent writes a plan, bmi.plan.json, in the format Zana's maker understands:
{
"schema": "zana.plan/1",
"module": "Bmi",
"entities": ["Measurement"],
"value_objects": [
{"name": "Height", "type": "float"},
{"name": "Weight", "type": "float"}
],
"repositories": ["Measurement"],
"features": [
{"name": "RecordMeasurement", "http": true, "fields": ["heightCm:float", "weightKg:float", "age:?int"]},
{"name": "ListMeasurements", "query": true, "http": true}
]
}
It describes one module with one entity, two value objects so that a height and a weight can never be invalid, a database repository, and two use cases exposed over HTTP. age:?int makes the age optional.
The agent then shows you what the plan would do, without writing anything:
php bin/console make:plan bmi.plan.json --dry-run
make:module Bmi
* would create modules/Bmi/BmiModule.php
* would update config/modules.php
…
make:repository Bmi Measurement
* would create modules/Bmi/Infrastructure/Persistence/DatabaseMeasurementRepository.php
* would create modules/Bmi/Infrastructure/Persistence/Migrations/2026_11_02_182832_create_measurements_table.php
* would update modules/Bmi/BmiModule.php
make:feature Bmi RecordMeasurement --field=heightCm:float --field=weightKg:float --field=age:?int --http
* would create modules/Bmi/Application/Features/RecordMeasurement/RecordMeasurementCommand.php
* would create modules/Bmi/Application/Features/RecordMeasurement/RecordMeasurementHandler.php
* would create modules/Bmi/Tests/Application/RecordMeasurementHandlerTest.php
* would create modules/Bmi/Presentation/Api/ApiInput.php
* would create modules/Bmi/Presentation/Api/RecordMeasurementAction.php
* would create modules/Bmi/Tests/Presentation/RecordMeasurementApiTest.php
* would update modules/Bmi/BmiModule.php
…
Dry run; nothing was written.
This is your moment to review. Check the module name, the fields and their types, and whether each feature should be a command or a query. Changing a line in the plan costs nothing now.
The plan is also validated. In our first attempt, the fields were written height_cm:float, and the maker refused the plan before writing a single file:
[ERROR] … The field "height_cm:float" must be name:type, with a camelCase name and the type string, int, float or bool (?type for an optional field).
The agent read the message, renamed the fields to heightCm and weightKg, and ran the dry run again.
Step 5: Generate the structure
Once you approve the plan:
php bin/console make:plan bmi.plan.json
php bin/console migrate
php bin/console check
composer test
make:plan runs the same make: commands you could type yourself, first in a temporary copy. Nothing is written unless every step succeeds:
[OK] Ran 7 steps of the plan.
The new module is already wired. Its services are registered, its handlers are mapped, its routes are loaded and it is listed in config/modules.php. check passes with Architecture: 2 module(s), no violation, and the tests run:
Tests: 23, Assertions: 79, Incomplete: 2.
The two incomplete tests are the handlers' tests. The generated handlers are empty on purpose. Zana generates the structure, and the business rules are still for you to write.
Step 6: Write the business logic
Now give the agent the rules. Be precise, because this is the part no generator can guess:
Write the business logic of the Bmi module.
- Height is in centimetres, between 30 and 300. Weight is in kilograms, between 1 and 700. Age, when given, is between 2 and 120. Refuse anything else with a 422 from the API, and also protect the value objects in the domain.
- BMI = weight ÷ (height in metres)², rounded to one decimal.
- Classify adults with the WHO cut-offs: under 18.5 underweight, under 25 normal, under 30 overweight, otherwise obesity. Under 20 years old, give no category, because children are assessed with BMI-for-age percentiles.
- Store every field, and return the measurement with a 201 when it is recorded.
- Replace the incomplete tests with real ones.
Here is what the agent wrote, and what you should review.
The value objects reject impossible values, whoever calls them:
final readonly class Height extends ValueObject
{
public float $centimetres;
public function __construct(float $centimetres)
{
if ($centimetres < 30 || $centimetres > 300) {
throw new DomainException('A height must be between 30 and 300 cm.');
}
$this->centimetres = $centimetres;
}
public function metres(): float
{
return $this->centimetres / 100;
}
}
The classification is a small enum in the domain:
enum Classification: string
{
case Underweight = 'underweight';
case Normal = 'normal';
case Overweight = 'overweight';
case Obesity = 'obesity';
public static function of(float $bmi, ?int $age): ?self
{
if ($age !== null && $age < 20) {
return null;
}
return match (true) {
$bmi < 18.5 => self::Underweight,
$bmi < 25.0 => self::Normal,
$bmi < 30.0 => self::Overweight,
default => self::Obesity,
};
}
}
The measurement computes its BMI once, when it is recorded:
public static function record(Height $height, Weight $weight, ?int $age, \DateTimeImmutable $measuredAt): self
{
if ($age !== null && $age < 0) {
throw new DomainException('An age must not be negative.');
}
$bmi = round($weight->kilograms / ($height->metres() ** 2), 1);
return new self(MeasurementId::generate(), $height, $weight, $age, $bmi, Classification::of($bmi, $age), $measuredAt);
}
The command declares its limits. The skeleton's ValidationBehavior checks them for every caller, whether that is the API, a console command or a queue job, and the API answers 422 with the field errors:
final class RecordMeasurementCommand extends Command
{
public function __construct(
#[Range(min: 30, max: 300)]
public readonly float $heightCm,
#[Range(min: 1, max: 700)]
public readonly float $weightKg,
#[Range(min: 2, max: 120)]
public readonly ?int $age = null,
) {
parent::__construct();
}
}
The handler connects the pieces:
#[Handles(RecordMeasurementCommand::class)]
final readonly class RecordMeasurementHandler
{
public function __construct(private MeasurementRepositoryInterface $measurements)
{
}
public function __invoke(RecordMeasurementCommand $command): array
{
$measurement = Measurement::record(
new Height($command->heightCm),
new Weight($command->weightKg),
$command->age,
new \DateTimeImmutable('now', new \DateTimeZone('UTC')),
);
$this->measurements->add($measurement);
return MeasurementData::from($measurement);
}
}
The rest follows the comments the maker left in the generated files:
- the migration gets its six columns;
- the repository's
row()andhydrate()map them, and alatest()method lists the newest measurements; ListMeasurementsHandlerreturns the 50 most recent;- the API action answers
201with the measurement instead of the generated204.
Each layer stays where AGENTS.md says it belongs. The domain knows nothing about HTTP or the database, and the API goes through the mediator.
Step 7: Tests that prove the rules
The agent replaced the two incomplete tests and added tests for the rules:
- domain:
170 cmand65 kggive22.5, classified normal; each WHO cut-off lands in the right category (18.4,18.5,24.9,25,30); a 12-year-old gets no category; a height of1.7, metres instead of centimetres, is refused; - application: the handler stores the measurement and returns its BMI, using an in-memory repository; the list comes newest first;
- API: a valid request answers
201with"bmi": 22.5; missing fields and a height in metres answer422with the field named.
The API tests run migrate in their setUp() against the in-memory SQLite database that the skeleton configures for tests in config/testing/. They never touch your real data or your .env.
Step 8: Check, test, and read the summary
php bin/console check
composer test
✔ The container builds
✔ Deployment checks: BuiltInChecks
✔ Deployment checks: LogFilesCheck
✔ All 65 singletons can be created
✔ Architecture: 2 module(s), no violation
✔ MiddlewareCheck
✔ RouteHandlersCheck
✔ MigrationsCheck
✔ AgentsCheck
[OK] The application passes its checks.
OK (31 tests, 110 assertions)
If the agent had broken a rule, for example by letting the API action call the repository directly, or by forgetting to register a service, check would have failed. The finding would have named the problem and given a hint on how to fix it. AGENTS.md tells the agent to fix every error before going on, and never to disable a check to make it pass.
The agent finishes with a summary of the files it created, the decisions it took (such as no category under 20, and value objects for height and weight) and the commands it ran with their results. That summary is what you review, together with the diff.
Step 9: Run it
The application caches its compiled container in var/cache. You changed the handlers' constructors after generating them, so clear the cache before starting the server. AGENTS.md says the same: never edit generated caches, clear them.
php bin/console cache:clear
composer serve
Record a measurement:
curl -X POST http://localhost:8000/api/bmi/record-measurement \
-H "Content-Type: application/json" \
-d '{"heightCm": 170, "weightKg": 65, "age": 30}'
{"data":{"id":"01a19d89-6e60-73fb-9e3e-170729503950","height_cm":170.0,"weight_kg":65.0,"age":30,"bmi":22.5,"classification":"normal","measured_at":"2026-11-02T18:33:12+00:00"}}
A child gets a BMI but no adult category:
{"data":{"id":"01a19d89-6ed6-7512-9a77-51b30950ca4a","height_cm":150.0,"weight_kg":45.0,"age":12,"bmi":20.0,"classification":null,"measured_at":"2026-11-02T18:33:12+00:00"}}
A height sent in metres is refused, with the reason:
{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"The request contains invalid data.","errors":{"heightCm":["This value should be between 30 and 300."]}}
In development, error responses also include the exception and its trace. In production, with APP_ENV=production, they do not.
And the history, newest first:
curl http://localhost:8000/api/bmi/list-measurements
What you built, and what you did
The whole change touches 32 files: 31 in modules/Bmi, plus one line in config/modules.php. The agent generated most of them with make:plan, then wrote the business logic and the tests. You did three things that matter:
- You described the request precisely, including the medical rule for under-20s.
- You reviewed the plan before any file was written.
- You reviewed the summary and the diff, knowing that
checkand 31 tests had already caught the mechanical mistakes.
That split is deliberate. Zana's tools catch wiring and architecture errors automatically. They cannot know whether a BMI rule is medically right, and that judgment stays with you.
Going further
Try these as your next requests to the agent, with the same workflow:
- Protect the endpoints: the skeleton already has users and bearer tokens (
POST /api/tokens). Ask the agent to require authentication on the BMI routes, asShowProfileActiondoes. - Add patients: a
Patientsmodule, with measurements linked to a patient through a contract between modules. The architecture rules make sure Bmi never reaches into Patients' internals. - Add a web page: a form and a history page, with
make:module --weband templates.
The full guide is in Zana's documentation: working with AI coding agents.
BMI is a screening measure, not a diagnosis. For children and teenagers, use the WHO or CDC BMI-for-age charts.
If you want help bringing this way of working to your team, let’s talk.


