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.
Product API Runtime Ingest API
http://localhost:8000/api/v1 http://localhost:8001/ingest/v1The 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:
HTTP request
│
▼
FastAPI route
│
▼
Service
├──────────────► Infrastructure adapter
│
▼
Repository
│
▼
PostgreSQLRoutes 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:
POST /ingest/v1/agents/{agent_id}/eventsProvider 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.
make setup
make runAPI-focused development
Start PostgreSQL, apply migrations, and run the Product and Ingest applications.
make db-up
make migrate
make dev-apimake 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
curl --fail-with-body \
http://localhost:8000/api/v1/healthA healthy response is:
{
"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
| Concern | Convention |
|---|---|
| Identifiers | UUID strings |
| Timestamps | Timezone-aware ISO 8601 values |
| JSON fields | Python and Pydantic field names, normally snake_case |
| Collection pagination | page, page_size, total, and items |
| Page numbering | Starts at 1 |
| Partial updates | PATCH with only explicitly supplied fields applied |
| Standard errors | FastAPI detail response |
| Validation errors | 422 with structured field errors |
| Deletes without a response body | 204 No Content |
A paginated response has this general shape:
{
"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.
-
Log in
The login endpoint accepts OAuth2 form data, not a JSON body. Supply the user’s email through the form field named
username.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:
{ "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.
-
Send the access token
Store the returned access token in a temporary environment variable:
export AGENT_BARN_ACCESS_TOKEN='replace-with-returned-access-token'Confirm the authenticated 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: Bearer <access-token> -
Refresh the session
A browser client can refresh using the HttpOnly 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:
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:
- Authenticated user
A valid, unexpired, correctly typed bearer access token identifies the caller.
- Active Organization
The authentication dependency reads organization_id from the route and resolves a real persisted Membership.
- Organization Permission
The service requires the specific Permission its operation needs, resolved from the caller’s Organization Role.
- Agent visibility
Repository queries conceal Agents and subordinate resources the caller cannot see, before counting and pagination.
- 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:
/api/v1/organizations/{organization_id}/...For example:
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:
/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
| Status | Meaning |
|---|---|
401 Unauthorized | The bearer token is absent, invalid, expired, revoked, or the wrong credential type |
403 Forbidden | The resource is visible, but the caller lacks the required action Permission |
404 Not Found | The resource does not exist or is concealed by Organization or Agent visibility |
409 Conflict | The request conflicts with lifecycle state, uniqueness, or another business invariant |
422 Unprocessable Entity | FastAPI 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:
- HTTP requestA client sends a bearer-authenticated request to a mounted application.
- FastAPI routePath, query, headers, and the request DTO are parsed and validated.
- Authentication and contextThe dependency resolves CurrentUserContext and, for Organization routes, the active Membership.
- ServiceBusiness rules, Organization and Agent permissions, orchestration, and domain-error translation.
- Repository or infrastructure adapterTenant-aware SQLModel queries, or Kubernetes, LiteLLM, OpenRouter, Slack, email, and encryption adapters.
- ResponseThe response DTO is serialized as the external contract.
Event-producing services branch here, before the response
- Domain-specific repository transactionOne session owns the business mutation and its event records.
- Outbox Message and Event DeliveriesAn immutable message plus one Event Delivery per intended handler.
- CommitBusiness state and delivery intent become durable together.
- 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:
api/domains/<domain>/
├── models.py
├── repository.py
├── service.py
└── routes.pyAdd 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.pyapi/domains/<domain>/repository.pyapi/domains/<domain>/service.pyapi/domains/<domain>/routes.pyapi/api_app.pyapi/migrations/versions/
-
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_factoryfor mutable defaults.For partial updates, distinguish an omitted field from a field explicitly set to
null:changes = update.model_dump(exclude_unset=True) -
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:
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 NULLbranch 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.
-
Add service behavior
Services derive the active Organization from
CurrentUserContext, enforce permissions, and coordinate the work.A representative Organization permission check is:
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 = self.agent_authorization.require_action( context, agent_id, PermissionKey.AGENT_UPDATE, )Resolve grants on each request. Never trust a client-supplied role,
allowed_actionsvalue, or permission flag.Translate expected domain failures in the service:
Domain failure Status Invalid business input 400Missing or concealed record 404Visible but disallowed action 403Uniqueness or lifecycle conflict 409 -
Add a thin route
A representative route follows this shape:
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:
Operation Status Read 200Update returning a body 200Create 201Delete or bodyless action 204Prefer
204to custom success objects. -
Use dependency injection
Domain classes normally use
injector:@inject @singleton @dataclass class SkillService: repository: SkillRepository permission_policy: PermissionPolicyFastAPI routes receive services using
Injected(...).Do not construct repositories, services, infrastructure clients, or database engines inside route handlers.
Shared infrastructure providers are configured through
AppModuleand composed bycreate_injector(). Add a provider when construction requires configuration or a deliberately selected implementation. -
Register the router
Register a new Product API router in
api/api_app.pyusing the existing composition pattern:subapi.include_router(skills_router)Register runtime telemetry routes through
api/ingest_app.py. -
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:
repository.save(first_record) → commit
repository.save(second_record) → commitIf 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:
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:
make makemigrations
make migrate
make check-migrationsReview 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:
make rollback
make migrateCheck 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.
| Change | Minimum coverage |
|---|---|
| Service business rule | Focused unit or service test plus integration behavior |
| Route, request, response, or authentication contract | Integration test |
| Repository query or tenant visibility | Repository or integration test using PostgreSQL |
| Agent or subordinate-resource access | Agent RBAC and cross-Organization integration coverage |
| Database schema | Migration plus integration coverage |
| Infrastructure adapter | Focused unit test; integration test when wiring or real protocol behavior matters |
| Kubernetes behavior | Kubernetes integration target |
| UI-consumed response | API 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:
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:
make check-api
make test-api
make check-migrationsmake check-api runs:
- Ruff lint checks
- Ruff formatting checks
- Python type checking
Run Kubernetes integration only when the changed contract depends on Kubernetes behavior:
make test-api-k8sIf the API contract is consumed by the web app, also run:
make lint-ui
make check-ui
make test-uiBefore 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.
| Symptom | Likely cause | What to check |
|---|---|---|
| Route returns 404 even though the ID exists | Tenant or Agent visibility is intentionally concealing the resource | Confirm the Organization in the URL, persisted Membership, Agent Access, and repository scope |
| Route returns 403 | The caller can reach the scope but lacks the required action Permission | Check Organization Role, Agent Access Role, general access, and the Permission requested by the service |
| Every protected request returns 401 | Missing, expired, invalid, or incorrectly typed bearer token | Log in again, send the access token rather than the refresh or Ingest token, and check the Authorization header |
| Login returns 422 | Login was sent as JSON or omitted OAuth2 fields | Use application/x-www-form-urlencoded and put the email in the username field |
| New route is missing from Swagger UI | The router was not included by its composition root | Check api/api_app.py or api/ingest_app.py |
| Health returns 503 | PostgreSQL cannot be queried | Check DB_CONNECTION_URL, the database container, logs, and migration state |
| API starts but new columns are missing | The migration was not created or applied | Review Alembic heads and run make migrate |
| A multi-step operation leaves partial data | Several session-per-operation repository calls were treated as one transaction | Move the required writes into an explicit repository-owned session |
| Agents run but activity does not appear | Ingest is not running, or the Agent has the wrong Ingest URL or key | Use make dev-api, check port 8001, and inspect the Agent’s runtime configuration |
| Browser request is blocked by CORS | The browser origin is not in the API’s allowed local origins | Use the normal same-origin application path, or deliberately update the deployment’s CORS boundary |
| Integration tests cannot start PostgreSQL | Docker or Testcontainers is unavailable | Start Docker and verify that the configured PostgreSQL image can be pulled and run |
| A list count reveals more records than its items | Visibility was applied after counting or pagination | Apply the same accessible-resource scope to both the item and count queries |
| UI fails after a response change | UI schema and fixtures still describe the previous DTO | Update Zod schemas, hooks, mocks, and browser tests with the API contract |