Develop and extend
Guide

Develop against the API

Use and extend Agent Barn’s FastAPI surfaces with correct authentication, Organization and Agent scoping, layering, transactions, migrations, and PostgreSQL-backed tests.

For
Backend and integration developers
On this page
  1. Overview
  2. Choose the API boundary
  3. Run the API
  4. Inspect the contract
  5. Authenticate requests
  6. Scope requests
  7. Follow the request lifecycle
  8. Add an API capability
  9. Preserve atomicity
  10. Evolve the schema
  11. Test the contract
  12. Verify the change
  13. Troubleshooting
  14. Next steps

Agent Barn exposes FastAPI interfaces for users, platform administrators, Agent runtimes, and provider callbacks. Successful API development begins by choosing the correct interface and preserving its authentication, tenancy, and transaction boundaries.

API outcome

What you will be able to do

By the end of this guide, you will be able to:

  • Inspect the live API contract
  • Authenticate a client against the Product API
  • Call an Organization-scoped endpoint
  • Trace a request through the backend layers
  • Add an API capability behind the correct boundary
  • Verify the change against migrated PostgreSQL

Consume the API

Building an integration or another client.

Focus on the generated OpenAPI contract, bearer authentication, Organization-scoped URLs, pagination, and HTTP errors.

Extend the backend

Contributing to Agent Barn.

Follow the route, service, and repository boundary, enforce permissions, design transactions deliberately, and cover the behavior with the appropriate tests.

The generated OpenAPI schema is the endpoint-level contract: it documents the current endpoints and DTOs. This guide explains the development workflow and the security and implementation boundaries those contracts sit inside.

Overview

Agent Barn has two FastAPI composition roots.

Local base URLs
Product API                       Runtime Ingest API
http://localhost:8000/api/v1      http://localhost:8001/ingest/v1

The Product API serves authenticated users, Organization members, and Platform Administrators. The Ingest API accepts telemetry emitted by running Agents.

They share application infrastructure, but they do not share an authentication contract. A user access token is for the Product API. A per-Agent Ingest key is for that Agent’s Ingest endpoint.

Dependency direction

The normal backend dependency direction is:

Backend dependency direction
HTTP request


FastAPI route


Service
    ├──────────────► Infrastructure adapter


Repository


PostgreSQL

Routes stay thin. Services make business and authorization decisions. Repositories own queries and persistence. Infrastructure adapters own external concerns such as Kubernetes, Slack, email, LiteLLM, OpenRouter, and encryption.

Choose the API boundary

Select the boundary before adding a route or writing a client.

Product API

Local address
http://localhost:8000/api/v1
Authentication
User bearer access token
Intended caller
Web app, human-operated clients, and user-context integrations

Platform routes

Local address
http://localhost:8000/api/v1/platform
Authentication
Verified Platform Administrator user session
Intended caller
Platform administration tools

Ingest API

Local address
http://localhost:8001/ingest/v1
Authentication
Per-Agent Ingest key in a bearer header
Intended caller
Running Agent telemetry plugins

Provider and operational endpoints

Local address
Product API process or root application
Authentication
Provider-specific or deployment-specific boundary
Intended caller
Teams callbacks, metrics collectors, and similar infrastructure

Product API route families

  • /auth
  • /organizations
  • /organizations/{organization_id}/members
  • /organizations/{organization_id}/agents
  • /organizations/{organization_id}/templates
  • /organizations/{organization_id}/skills
  • /organizations/{organization_id}/shared-credentials
  • /organizations/{organization_id}/costs
  • /integrations
  • /platform/users
  • /platform/organizations
  • /platform/templates
  • /platform/skills
  • /platform/stats
  • /platform/event-deliveries

The Ingest surface

The Ingest surface is intentionally narrow:

Ingest endpoint
POST /ingest/v1/agents/{agent_id}/events

Provider webhooks are also separate trust boundaries. Microsoft Teams messages arrive through /api/v1/webhooks/teams/{agent_id}/messages and are handled using provider-specific verification and relay behavior rather than the normal user-session dependency.

Run the API

Full local stack

Follow Run Agent Barn locally and start the stack from the repository root.

Full stack
make setup
make run

API-focused development

Start PostgreSQL, apply migrations, and run the Product and Ingest applications.

API only
make db-up
make migrate
make dev-api

make dev-api starts:

  • Product API on port 8000
  • Ingest API on port 8001

It also supplies the host-facing Ingest URL used by Agents running in the local Kubernetes cluster.

Verify the API and its database connection

Health check
curl --fail-with-body \
  http://localhost:8000/api/v1/health

A healthy response is:

Healthy response
{
  "status": "ok",
  "db": "connected"
}

A 503 response means the API process is reachable but cannot query PostgreSQL.

Inspect the contract

FastAPI publishes interactive documentation and the machine-readable OpenAPI schema for each mounted application.

Swagger UI

Interactive documentation for each mounted application.

Product API
http://localhost:8000/api/v1/docs
Ingest API
http://localhost:8001/ingest/v1/docs

OpenAPI JSON

The machine-readable endpoint-level contract.

Product API
http://localhost:8000/api/v1/openapi.json
Ingest API
http://localhost:8001/ingest/v1/openapi.json

Health

Proves the API process can query application PostgreSQL.

Product API
http://localhost:8000/api/v1/health

Metrics

Prometheus exposition on each application root.

Product API
http://localhost:8000/metrics
Ingest API
http://localhost:8001/metrics

Use the OpenAPI document to confirm:

  • HTTP method and path
  • Required path and query parameters
  • Request content type
  • Request DTO
  • Response DTO
  • Validation constraints
  • Status codes
  • Whether bearer authentication is required

Treat the response DTO as the external contract. A database model may contain encrypted, internal, or operational fields that must never appear in an API response.

Contract conventions

ConcernConvention
IdentifiersUUID strings
TimestampsTimezone-aware ISO 8601 values
JSON fieldsPython and Pydantic field names, normally snake_case
Collection paginationpage, page_size, total, and items
Page numberingStarts at 1
Partial updatesPATCH with only explicitly supplied fields applied
Standard errorsFastAPI detail response
Validation errors422 with structured field errors
Deletes without a response body204 No Content

A paginated response has this general shape:

Paginated response
{
  "page": 1,
  "page_size": 15,
  "total": 2,
  "items": [
    {
      "id": "00000000-0000-0000-0000-000000000000"
    }
  ]
}

Do not infer writable fields from a read response. Inspect the endpoint’s *Create or *Update schema.

Authenticate requests

Product API calls normally use a user-session bearer access token.

  1. Log in

    The login endpoint accepts OAuth2 form data, not a JSON body. Supply the user’s email through the form field named username.

    Log in
    curl --request POST \
      --url http://localhost:8000/api/v1/auth/login \
      --header 'Content-Type: application/x-www-form-urlencoded' \
      --data-urlencode '[email protected]' \
      --data-urlencode 'password=replace-with-your-password'

    The response contains an access token, refresh token, and bearer token type:

    Token response
    {
      "access_token": "replace-with-returned-access-token",
      "refresh_token": "replace-with-returned-refresh-token",
      "token_type": "bearer"
    }

    The server also writes the refresh token to an HttpOnly cookie. Local and test environments use local-compatible cookie settings; deployed environments use secure cookie settings.

  2. Send the access token

    Store the returned access token in a temporary environment variable:

    Store the token
    export AGENT_BARN_ACCESS_TOKEN='replace-with-returned-access-token'

    Confirm the authenticated user:

    Current user
    curl --fail-with-body \
      http://localhost:8000/api/v1/auth/me \
      --header "Authorization: Bearer ${AGENT_BARN_ACCESS_TOKEN}"

    All protected Product API requests use the same header:

    Authorization header
    Authorization: Bearer <access-token>
  3. Refresh the session

    A browser client can refresh using the HttpOnly cookie:

    Refresh with the cookie
    curl --request POST \
      --url http://localhost:8000/api/v1/auth/refresh \
      --cookie 'refresh_token=replace-with-refresh-token'

    A non-browser client can supply the refresh token in the request body:

    Refresh with a request body
    curl --request POST \
      --url http://localhost:8000/api/v1/auth/refresh \
      --header 'Content-Type: application/json' \
      --data '{
        "refresh_token": "replace-with-refresh-token"
      }'

    Refresh tokens are rotated. Replace the stored access and refresh tokens with the values returned by the refresh request.

Scope requests

Authentication establishes who the caller is. It does not automatically grant access to every Organization or Agent.

Most Product API requests resolve access through this sequence:

  1. Authenticated user

    A valid, unexpired, correctly typed bearer access token identifies the caller.

  2. Active Organization

    The authentication dependency reads organization_id from the route and resolves a real persisted Membership.

  3. Organization Permission

    The service requires the specific Permission its operation needs, resolved from the caller’s Organization Role.

  4. Agent visibility

    Repository queries conceal Agents and subordinate resources the caller cannot see, before counting and pagination.

  5. Agent action Permission

    Effective Agent permissions are resolved again on the server for every mutation.

Organization scope

Organization routes carry the Organization UUID in the URL:

Organization-scoped route
/api/v1/organizations/{organization_id}/...

For example:

List Agents in an Organization
export AGENT_BARN_ORGANIZATION_ID='replace-with-organization-id'

curl --fail-with-body \
  "http://localhost:8000/api/v1/organizations/${AGENT_BARN_ORGANIZATION_ID}/agents?page=1&page_size=15" \
  --header "Authorization: Bearer ${AGENT_BARN_ACCESS_TOKEN}"

The authentication dependency reads organization_id from the route and resolves a real persisted Membership for the user.

Routes without an organization_id path parameter do not have an active Organization. Examples include login, the current-user endpoint, Organization creation, and Platform Administrator routes.

Platform scope

Platform administration routes live under:

Platform route prefix
/api/v1/platform/...

These routes use the Platform Administrator dependency and do not synthesize an active Organization.

Agent scope

Agent authorization adds another layer beneath Organization Membership.

Organization Owners and Administrators receive the documented implicit Agent authority. Other members can receive access through:

  • Explicit Agent Access
  • An Agent Access Role
  • Agent General Access, where applicable
  • The union of effective Agent permissions

Agent and subordinate-resource queries must enforce visibility in the repository before counting or paginating results. This applies to:

  • Conversations
  • Tool Calls
  • Activity
  • Logs
  • Costs
  • Secrets
  • Skills assigned to an Agent
  • Agent configuration

The API may include allowed_actions in Agent read responses so clients can render appropriate controls. That field is a client hint, not authorization proof. The backend resolves permissions again for every mutation.

HTTP security semantics

StatusMeaning
401 UnauthorizedThe bearer token is absent, invalid, expired, revoked, or the wrong credential type
403 ForbiddenThe resource is visible, but the caller lacks the required action Permission
404 Not FoundThe resource does not exist or is concealed by Organization or Agent visibility
409 ConflictThe request conflicts with lifecycle state, uniqueness, or another business invariant
422 Unprocessable EntityFastAPI or Pydantic rejected the request shape or field values

Tenant-sensitive resources generally return 404 when an identifier belongs to another Organization. This prevents the response from confirming that the resource exists.

Organization administration can return 403 when the caller is known but lacks Organization authority. Always inspect the endpoint’s tested contract instead of converting every authorization failure to the same status.

Follow the request lifecycle

A normal Product API request follows this path:

Product API request lifecycle
  1. HTTP requestA client sends a bearer-authenticated request to a mounted application.
  2. FastAPI routePath, query, headers, and the request DTO are parsed and validated.
  3. Authentication and contextThe dependency resolves CurrentUserContext and, for Organization routes, the active Membership.
  4. ServiceBusiness rules, Organization and Agent permissions, orchestration, and domain-error translation.
  5. Repository or infrastructure adapterTenant-aware SQLModel queries, or Kubernetes, LiteLLM, OpenRouter, Slack, email, and encryption adapters.
  6. ResponseThe response DTO is serialized as the external contract.

Event-producing services branch here, before the response

  1. Domain-specific repository transactionOne session owns the business mutation and its event records.
  2. Outbox Message and Event DeliveriesAn immutable message plus one Event Delivery per intended handler.
  3. CommitBusiness state and delivery intent become durable together.
  4. Immediate dispatch or reconciliationHandlers run after the transaction boundary, at least once.

The business change and its event records must commit together. Event handling occurs after that transaction boundary.

The layer boundaries are deliberate. Each layer owns a distinct set of decisions.

Route

  • Declare the HTTP method and path
  • Declare request and response DTOs
  • Resolve authentication dependencies
  • Validate path and query parameters
  • Receive injected services
  • Call one service operation
  • Return the result

A route must not contain a business workflow, SQL query, or database session.

Service

  • Enforce business invariants
  • Require Organization or Agent permissions
  • Orchestrate repositories and infrastructure adapters
  • Decide lifecycle and conflict behavior
  • Translate domain failures into the appropriate API error
  • Establish or call an explicit transaction boundary when required

Repository

  • Own SQLModel and SQLAlchemy queries
  • Scope tenant-sensitive records
  • Apply Agent visibility before count and pagination
  • Persist database state
  • Expose explicitly typed public operations
  • Own a multi-write transaction when the workflow requires one

Add an API capability

Start by reading the neighboring domain. New domains normally use:

Domain layout
api/domains/<domain>/
├── models.py
├── repository.py
├── service.py
└── routes.py

Add another file only when it owns a distinct responsibility such as parsing, building, provider behavior, or domain exceptions.

The workflow below usually touches:

  • api/domains/<domain>/models.py
  • api/domains/<domain>/repository.py
  • api/domains/<domain>/service.py
  • api/domains/<domain>/routes.py
  • api/api_app.py
  • api/migrations/versions/
  1. Define the contract

    Create separate types for persistence and API input and output. Database models and API DTOs remain distinct types.

    Use the established suffixes:

    • *Create
    • *Update
    • *Read
    • *Filter

    Keep encrypted and internal fields out of read DTOs. Use default_factory for mutable defaults.

    For partial updates, distinguish an omitted field from a field explicitly set to null:

    Partial update
    changes = update.model_dump(exclude_unset=True)
  2. Add repository behavior

    Repositories own query composition.

    An Organization-scoped lookup must include its tenant boundary in the query. Do not fetch a globally addressed row and filter it in the service afterward.

    The Skill repository demonstrates the pattern:

    Tenant-scoped lookup
    def get_by_id_for_org(self, skill_id: UUID, org_id: UUID) -> Skill | None:
        with Session(self.delegate.engine) as session:
            return session.exec(
                select(Skill).where(
                    col(Skill.id) == skill_id,
                    or_(
                        col(Skill.organization_id) == org_id,
                        col(Skill.organization_id).is_(None),
                    ),
                )
            ).first()

    The organization_id IS NULL branch is specific to globally available built-in Skills. Do not copy that allowance into a domain whose records must always belong to one Organization.

    For Agent or subordinate-resource collections, reuse the existing accessible-Agent query pattern. Visibility must affect both the result query and the count query.

  3. Add service behavior

    Services derive the active Organization from CurrentUserContext, enforce permissions, and coordinate the work.

    A representative Organization permission check is:

    Organization permission
    org_id = context.require_current_user_organization().organization_id
    
    self.permission_policy.require_organization(
        context,
        org_id,
        PermissionKey.SKILL_MANAGE,
    )

    For Agent resources, use the shared Agent authorization module rather than reproducing role checks:

    Agent action permission
    agent = self.agent_authorization.require_action(
        context,
        agent_id,
        PermissionKey.AGENT_UPDATE,
    )

    Resolve grants on each request. Never trust a client-supplied role, allowed_actions value, or permission flag.

    Translate expected domain failures in the service:

    Domain failureStatus
    Invalid business input400
    Missing or concealed record404
    Visible but disallowed action403
    Uniqueness or lifecycle conflict409
  4. Add a thin route

    A representative route follows this shape:

    Route pattern
    skills_router = APIRouter(
        prefix="/organizations/{organization_id}/skills",
        tags=["skills"],
    )
    
    
    @skills_router.post(
        "",
        response_model=SkillSummaryRead,
        status_code=status.HTTP_201_CREATED,
    )
    def create_skill(
        data: SkillCreate,
        context: Annotated[
            CurrentUserContext,
            Depends(get_current_user()),
        ],
        service: Annotated[
            SkillService,
            Injected(SkillService),
        ],
    ):
        return service.create_skill(data, context)

    The route declares the contract and delegates the workflow. The authentication dependency resolves the Organization ID from the request path.

    Use the established status conventions:

    OperationStatus
    Read200
    Update returning a body200
    Create201
    Delete or bodyless action204

    Prefer 204 to custom success objects.

  5. Use dependency injection

    Domain classes normally use injector:

    Injected service
    @inject
    @singleton
    @dataclass
    class SkillService:
        repository: SkillRepository
        permission_policy: PermissionPolicy

    FastAPI routes receive services using Injected(...).

    Do not construct repositories, services, infrastructure clients, or database engines inside route handlers.

    Shared infrastructure providers are configured through AppModule and composed by create_injector(). Add a provider when construction requires configuration or a deliberately selected implementation.

  6. Register the router

    Register a new Product API router in api/api_app.py using the existing composition pattern:

    Router registration
    subapi.include_router(skills_router)

    Register runtime telemetry routes through api/ingest_app.py.

  7. Synchronize consumers and documentation

    When an API contract changes, check every consumer. Update, as applicable:

    • Response and request DTOs
    • UI Zod schemas
    • UI query hooks and mutations
    • Mock responses
    • Playwright fixtures and expectations
    • Generated or external clients
    • Authoritative architecture or feature documentation

    If a change revises a domain invariant, authorization boundary, lifecycle state, or operational contract, update the corresponding document in the same application-repository change.

Preserve atomicity

PostgresRepositoryDelegate is appropriate for ordinary persistence. Its operations open and commit a database session per operation.

That means this service workflow is not automatically atomic:

Session per operation
repository.save(first_record)    → commit
repository.save(second_record)   → commit

If the second operation fails, the first may already be durable.

Ordinary operation

Use the shared delegate when:

  • One persisted operation is independently valid
  • Partial completion is acceptable
  • No outbox records must commit with the change
  • The domain does not require a lock or concurrency-safe invariant

Explicit transaction

Use a repository-owned transaction when:

  • Several writes form one invariant
  • A quota or state transition must be concurrency-safe
  • Rows must be locked
  • An event must commit with the business mutation
  • Partial persistence would leave invalid state

One transaction for business state and events

An event-producing mutation persists business state, an immutable Outbox Message, and the intended Event Deliveries in one transaction. The repository should own the session:

Repository-owned transaction
with Session(self.delegate.engine, expire_on_commit=False) as session:
    session.add(resource)
    session.flush()

    outbox_repository.stage(
        session=session,
        registry=EVENT_REGISTRY,
        event=event,
    )

    session.commit()

The outbox staging operation must use the caller’s session. It must not open or commit another session.

After the transaction commits, enqueue immediate delivery or allow reconciliation to process the pending Event Deliveries.

Do not pass database sessions into routes. Avoid holding a database transaction open across a slow external network call unless the domain explicitly requires that failure behavior.

Evolve the schema

A database contract change is more than a model edit. Update all affected surfaces:

  • Database model
  • Request DTO
  • Response DTO
  • Repository query
  • PostgreSQL constraints or indexes
  • Alembic migration
  • Integration-test setup and assertions
  • UI Zod schema when the web app consumes the contract
  • Authoritative feature or architecture documentation when an invariant changes

Create a migration from the repository root, review it, and apply it:

Migration commands
make makemigrations
make migrate
make check-migrations

Review the generated migration under api/migrations/versions/, then confirm the migration graph has one head with make check-migrations.

Exercise the immediate downgrade when rollback is supported:

Rollback rehearsal
make rollback
make migrate

Check PostgreSQL-specific behavior explicitly:

  • Foreign-key ownership
  • ON DELETE behavior
  • Nullability
  • Uniqueness
  • Indexes
  • Check constraints
  • Enum creation and removal
  • Server defaults
  • Migration order
  • Data backfills for existing rows

Test the contract

Choose the lowest test layer that proves the behavior reliably, then add integration coverage when the HTTP, authentication, tenancy, database, or composition contract is part of the change.

ChangeMinimum coverage
Service business ruleFocused unit or service test plus integration behavior
Route, request, response, or authentication contractIntegration test
Repository query or tenant visibilityRepository or integration test using PostgreSQL
Agent or subordinate-resource accessAgent RBAC and cross-Organization integration coverage
Database schemaMigration plus integration coverage
Infrastructure adapterFocused unit test; integration test when wiring or real protocol behavior matters
Kubernetes behaviorKubernetes integration target
UI-consumed responseAPI integration coverage plus UI schema and type verification

How API integration tests run

API integration tests use:

  • The real FastAPI application
  • A migrated PostgreSQL database
  • PostgreSQL Testcontainers
  • Additive Injector overrides
  • Reusable Given/When/Then steps
  • PyHamcrest assertions

Test shape

A representative test uses Given/When/Then steps and PyHamcrest assertions:

Integration test shape
def test_member_cannot_read_another_organizations_agent():
    with given([
        prepare_injector(),
        prepare_api_server(),
        create_test_client(),
        there_is_a_user(),
        there_is_an_access_token_for_user(),
        there_is_an_agent_in_another_organization(),
    ]) as context:
        with when("the member requests the other Organization's Agent"):
            response = context.client.get(
                context.agent_url,
                headers={
                    "Authorization": f"Bearer {context.access_token}",
                },
            )

            with then("the concealed Agent is not found"):
                assert_that(
                    response.status_code,
                    equal_to(status.HTTP_404_NOT_FOUND),
                )

Keep shared setup in the existing test support structure instead of embedding large arrangements in every test.

What to cover

  • Happy path
  • Unauthenticated request
  • Missing Organization Membership
  • Insufficient Organization Permission
  • Missing Agent access
  • Visible Agent with insufficient action Permission
  • Cross-Organization identifier
  • Important validation failures
  • Not-found behavior
  • State or uniqueness conflicts
  • Migration behavior when the schema changes

Verify the change

Run the API checks from the repository root:

API verification
make check-api
make test-api
make check-migrations

make check-api runs:

  • Ruff lint checks
  • Ruff formatting checks
  • Python type checking

Run Kubernetes integration only when the changed contract depends on Kubernetes behavior:

Kubernetes integration
make test-api-k8s

If the API contract is consumed by the web app, also run:

Web app checks
make lint-ui
make check-ui
make test-ui

Before handing off the change, confirm

  • The route appears in the Product or Ingest OpenAPI document
  • Request and response DTOs expose only intended fields
  • Organization and Agent scoping occurs before results are returned
  • The correct Permission is resolved on every mutation
  • Cross-Organization identifiers cannot reveal resources
  • All required writes share one transaction where necessary
  • Schema changes include a reviewed migration
  • Happy paths and important failures are covered
  • Related UI schemas and documentation remain synchronized
  • The change contains no unrelated refactor

Troubleshooting

404 conceals

A tenant-sensitive resource returns 404 when it belongs to another Organization or is hidden by Agent visibility. The response deliberately does not confirm that the resource exists, so a 404 is not proof of a bad identifier.

403 refuses

A 403 means the caller reached the scope and the resource is visible, but the required action Permission is missing. Fix the grant, not the identifier.

SymptomLikely causeWhat to check
Route returns 404 even though the ID existsTenant or Agent visibility is intentionally concealing the resourceConfirm the Organization in the URL, persisted Membership, Agent Access, and repository scope
Route returns 403The caller can reach the scope but lacks the required action PermissionCheck Organization Role, Agent Access Role, general access, and the Permission requested by the service
Every protected request returns 401Missing, expired, invalid, or incorrectly typed bearer tokenLog in again, send the access token rather than the refresh or Ingest token, and check the Authorization header
Login returns 422Login was sent as JSON or omitted OAuth2 fieldsUse application/x-www-form-urlencoded and put the email in the username field
New route is missing from Swagger UIThe router was not included by its composition rootCheck api/api_app.py or api/ingest_app.py
Health returns 503PostgreSQL cannot be queriedCheck DB_CONNECTION_URL, the database container, logs, and migration state
API starts but new columns are missingThe migration was not created or appliedReview Alembic heads and run make migrate
A multi-step operation leaves partial dataSeveral session-per-operation repository calls were treated as one transactionMove the required writes into an explicit repository-owned session
Agents run but activity does not appearIngest is not running, or the Agent has the wrong Ingest URL or keyUse make dev-api, check port 8001, and inspect the Agent’s runtime configuration
Browser request is blocked by CORSThe browser origin is not in the API’s allowed local originsUse the normal same-origin application path, or deliberately update the deployment’s CORS boundary
Integration tests cannot start PostgreSQLDocker or Testcontainers is unavailableStart Docker and verify that the configured PostgreSQL image can be pulled and run
A list count reveals more records than its itemsVisibility was applied after counting or paginationApply the same accessible-resource scope to both the item and count queries
UI fails after a response changeUI schema and fixtures still describe the previous DTOUpdate Zod schemas, hooks, mocks, and browser tests with the API contract

Next steps

Documentation