Server Transports
gen_servers takes your API surface — the merged ApiOutput from gen_api — and generates server-side handler code for up to three transport targets. Each transport produces the same operations with the same behavior, just adapted to its protocol.
The three transports
Section titled “The three transports”| Transport | Protocol | Output | Use case |
|---|---|---|---|
| HTTP/Axum | REST over HTTP | Axum route handlers + router function | Web APIs, external integrations |
| Tauri IPC | Inter-process communication | Tauri #[command] handlers + ipc_handler() | Desktop apps with Tauri |
| MCP | Model Context Protocol | Tool definitions + handle_tool_call() | AI assistant integrations |
You enable each transport independently through ServerGeneratorConfig variants. Most projects use one or two; having all three is for applications that serve web clients, desktop clients, and AI tools from the same codebase.
ServersConfig
Section titled “ServersConfig”let servers_output = ontogen::gen_servers( &schema, Some(&api_output), &["src/api/v1".into()], &ontogen::ServersConfig { api_dir: "src/api/v1".into(), state_type: "AppState".to_string(), service_import_path: "crate::api::v1".to_string(), types_import_path: "crate::schema".to_string(), state_import: "crate::AppState".to_string(), naming: ontogen::servers::NamingConfig::default(), generators: vec![ ontogen::servers::ServerGenerator::HttpAxum { output: "src/api/transport/http/generated.rs".into(), }, ontogen::servers::ServerGenerator::TauriIpc { output: "src/api/transport/ipc/generated.rs".into(), }, ontogen::servers::ServerGenerator::Mcp { output: "src/api/transport/mcp/generated.rs".into(), }, ], sse_route_overrides: Default::default(), route_prefix: None, store_type: Some("Store".to_string()), store_import: Some("crate::store::Store".to_string()), pagination: None, extra_surfaces: vec![], error_source_dir: Some("src/schema".into()), },)?;Let’s walk through the important fields:
| Field | Purpose |
|---|---|
api_dir | Directory to scan for API source files |
state_type | Your AppState type name, used in handler signatures |
service_import_path | Rust import path for API modules (e.g., crate::api::v1) |
types_import_path | Import path for schema/DTO types |
state_import | Full import path for the state type |
naming | Pluralization and URL naming overrides |
generators | Which server transports to generate |
store_type | Store type name for entity-scoped functions |
store_import | Full import path for the Store type |
route_prefix | Optional scope prepended to every route — see Route prefix |
pagination | Optional pagination for list operations |
error_source_dir | Directory scanned for your enum AppError, which decides the status and code of each HTTP error — see Errors |
The first argument is the parsed schema (parse_schema’s output, or &SchemaOutput::default() when you have none). A module is served as a JSON:API resource when its name is the module name of one of its entities. Pipeline passes the schema it parsed and sets error_source_dir to its schema directory when you leave it unset.
The full field list is in the ServersConfig reference.
HTTP/Axum generation
Section titled “HTTP/Axum generation”The HTTP generator produces Axum route handlers and an entity_routes() function. Every generated route except an event stream speaks JSON:API 1.1. CRUD routes for every module backed by a schema entity carry resource documents, and its relationships get relationship endpoints, which also serve its junction ops. Custom ops, and the junction and CRUD ops of a module with no entity behind it, carry meta-only documents, described under Operations that are not CRUD. JSON:API is the HTTP wire only: IPC and MCP carry flat entities.
This section describes the wire as a user of it needs it. The normative statement, with every rule, its check order and the reason for each choice, is the JSON:API wire contract. To see real captured requests and responses, see the wire pages for tasks-tracker, iron-log, iron-log-md and notes-kb. Moving a server or client from an earlier release is covered in Upgrading.
The generated server depends on the ontogen-jsonapi runtime crate, which holds the document types, link building, error documents and request extractors. It also calls ontogen_core::id::validate_id to check a client-supplied id, and builds request fields with serde_json. Add all three to the crate that contains the generated HTTP file, the way you add markdown-store (the examples use path dependencies):
ontogen-jsonapi = "…"ontogen-core = "…"serde_json = "1"The version of ontogen-jsonapi is the one released with the ontogen version you build with.
Route mapping
Section titled “Route mapping”Each OpKind maps to a specific HTTP method and path pattern:
| Operation | Method | Route pattern | Example |
|---|---|---|---|
List | GET | /api/{entities} | GET /api/tasks |
GetById | GET | /api/{entities}/{id} | GET /api/tasks/{id} |
Create | POST | /api/{entities} | POST /api/tasks |
Update | PATCH | /api/{entities}/{id} | PATCH /api/tasks/{id} |
Delete | DELETE | /api/{entities}/{id} | DELETE /api/tasks/{id} |
CustomGet | GET | /api/{entities}/{action} | GET /api/tasks/overdue |
CustomPost | POST | /api/{entities}/{action} | POST /api/tasks/close-by-project |
| Relationship | GET, PATCH, POST, DELETE | /api/{entities}/{id}/relationships/{rel} | POST /api/tasks/{id}/relationships/tags |
| Related resource | GET | /api/{entities}/{id}/{rel} | GET /api/tasks/{id}/epic |
JunctionList, no entity | GET | /api/{m}/{parent_id}/{children} | GET /api/agents/{id}/roles |
JunctionAdd, no entity | POST | /api/{m}/{parent_id}/{children} | POST /api/agents/{id}/roles |
JunctionRemove, no entity | DELETE | /api/{m}/{parent_id}/{children}/{child_id} | DELETE /api/agents/{id}/roles/{child_id} |
EventStream | GET (SSE) | /api/events/{name}[/{param}] | GET /api/events/vault-changes/{vault_id} |
The relationship and related routes exist for each entity whose module serves get_by_id and that has a relationship. In such a module, junction ops (list_X, add_Y, remove_Y) have no route of their own: they define the relationship X and are reached through these two routes. A one-parameter list_X with no add_Y or remove_Y beside it is a CustomGet, at /api/{entities}/{action}/{param}.
Entity names in URLs are pluralized and kebab-cased automatically: work_session becomes work-sessions, unit_of_work becomes units-of-work. The same string is the resource type in documents.
PUT is not routed on a resource: a PUT to /api/tasks/{id} answers 405 with Allow: GET, HEAD, PATCH, DELETE, and on a relationship route with Allow: GET, HEAD, PATCH, POST, DELETE. A method a route does not serve is a 405 with an Allow header and a method_not_allowed error document: every generated route carries its own method fallback. There is no fallback for paths: unmatched paths fall through to your own router.
Media type
Section titled “Media type”Every response from a generated route that carries a body has Content-Type: application/vnd.api+json, with no parameters, and every response, errors and 204s included, carries Vary: Accept. Event streams are the exception: they answer text/event-stream and do not check Accept (see Event subscriptions).
- A request with a body (
POST,PATCH, a relationshipDELETE; for a custom op, only when it sends one) must sendContent-Type: application/vnd.api+json. Anything else, includingapplication/jsonor noContent-Typeat all, is415 unsupported_media_typewithsource.header: "Content-Type". Anextparameter, or any parameter other thanprofile, is also415. A body on aGETor a resourceDELETEis ignored. - A missing
Acceptis acceptable. WhenAcceptlistsapplication/vnd.api+json, one of those entries must have no parameter other thanprofileandq, and a non-zeroq; otherwise the request is406 not_acceptable, even when*/*is listed too. When it does not list it,*/*orapplication/*satisfies it, and anything else (text/htmlalone) is406. A406carriessource.header: "Accept". 204responses have no body and noContent-Type.[and]in query parameter names are accepted raw or percent-encoded. Links the server writes always percent-encode them.
Documents and resources
Section titled “Documents and resources”A success response is a document with jsonapi (always {"version": "1.1"}), links, optional meta, data and, when the request asked for it, included, in that order. A resource object has type, id, attributes, relationships and links.self. This is GET /api/tasks/ship-the-emitter in the tasks-tracker example:
{ "jsonapi": { "version": "1.1" }, "links": { "self": "/api/tasks/ship-the-emitter" }, "data": { "type": "tasks", "id": "ship-the-emitter", "attributes": { "title": "Ship the emitter", "status": "closed/done", "created": "2026-06-06", "body": "## Goal\n\nEmit markdown CRUD matching the golden spec. ^summary\n\n## Outcome\n\nMatched on the first conformance run.\n" }, "relationships": { "epic": { "links": { "self": "/api/tasks/ship-the-emitter/relationships/epic", "related": "/api/tasks/ship-the-emitter/epic" }, "data": { "type": "epics", "id": "markdown-backend" } }, "tags": { "links": { "self": "/api/tasks/ship-the-emitter/relationships/tags", "related": "/api/tasks/ship-the-emitter/tags" }, "data": [ { "type": "tags", "id": "codegen" } ] }, "parent": { "links": { "self": "/api/tasks/ship-the-emitter/relationships/parent", "related": "/api/tasks/ship-the-emitter/parent" }, "data": null }, "subtasks": { "links": { "self": "/api/tasks/ship-the-emitter/relationships/subtasks", "related": "/api/tasks/ship-the-emitter/subtasks" }, "data": [] } }, "links": { "self": "/api/tasks/ship-the-emitter" } }}idis the entity’s#[ontology(id)]field, which must be aString: a build that generates the HTTP server or an HTTP client refuses any other id type.attributesholds every other non-relation field under its Rust field name.Optionfields that areNonearenull.- Each relation field becomes a relationship. A
belongs_tofield namedepic_idbecomesepic; other relation fields keep their name. A relationship object carrieslinksto its relationship endpoints and itsdata. The relationships of the module’s junction ops follow the relation fields, withlinksonly. When the module serves noget_by_idthere are no relationship endpoints, and relationship objects carrydataonly. A type with no relationship has norelationshipsmember. See Relationships for each kind’s linkage. - Links are relative paths. Ids in links are percent-encoded.
- A module that serves no
get_by_idhas no route at/api/{type}/{id}, so its resource objects carry nolinks, a create sends noLocation, and its create and update documents have no top-levellinks.
| Operation | Success |
|---|---|
List | 200, data is an array ([] when empty), in sort order, or by id ascending without one |
GetById | 200, data is one resource; a missing id is the store’s 404 {entity}_not_found |
Create | 201, Location header equal to links.self (when the module serves get_by_id), data is the created resource |
Update | 200, data is the full updated resource |
Delete | 204, no body |
An unpaginated list’s links holds only self, and it has no meta: the whole set is in data. A paginated list adds meta and the page links (see Pagination support). A path {id} is only a lookup key: it is never validated, so an id that does not exist, or could never be created, is the store’s 404.
A create body is {"data": {"type": "tasks", "attributes": {...}, "relationships": {...}}}. A required attribute or a non-Option to-one that is absent is 400 (missing_attribute, missing_relationship); an attribute the type does not have, including a relation field sent as one (epic_id), is 400 unknown_attribute. The id follows the store’s IdStrategy, which works the same on both backends:
- A client
data.idis honoured under every strategy (Provided,SlugFromField,Uuid). It must be a valid id: lowercase ASCII letters, digits,.,_,~and-, 1 to 200 bytes, not starting or ending with., notindexorlog, and not a Windows device name. Anything else is400 invalid_documentat/data/id. - Without one the store derives it: a slug of the source field, deduplicated with
-2,-3, … so a derived id never collides, or a UUID. When there is nothing to derive from (Provided, or a slug source that slugifies to nothing), the create is400 {entity}_id_required. - A client id that already exists is
409 {entity}_already_exists, withsource.pointer: "/data/id".
A PATCH body must carry data.type and a data.id equal to the URL id (409 id_mismatch otherwise). An attribute or relationship that is absent is left unchanged; null clears an Option field or an optional to-one; a to-many data array replaces the whole list. A PATCH with neither attributes nor relationships writes nothing and answers 200 with the current resource. A PATCH to a missing id is 404 once the body has passed its checks.
Before it creates or updates, the handler checks that every linked resource exists and answers 404 no_such_related_resource otherwise, so a markdown vault never receives a dangling wikilink.
Compound documents
Section titled “Compound documents”GET /api/{type} and GET /api/{type}/{id} accept include: a comma-separated list of relationship names, one level deep. The related resources arrive in a top-level included member after data, each once, in include order and then in order of first appearance. GET /api/tasks?include=epic,tags returns the page’s tasks plus their epics and tags. Every link on the document carries the include value, so next keeps it, and on a get the top-level self is /api/tasks/ship-the-emitter?include=epic.
- Each included resource is read through the target module’s
get_by_id, one read per distinct id. A dangling id is left out ofincludedand stays in the linkage. - A field relationship can be included when its target module serves
get_by_idin the handler’s scope. A junction-op relationship cannot. - An unknown name, a dotted path (
epic.tasks), an empty item (epic,) and a relationship that cannot be included are400 invalid_include_path. - Relationship and related routes do not accept
include. - The generated TypeScript transport does not expose
include. It is an HTTP-only feature, because the flat return shape has nowhere to put included resources.
Relationship endpoints
Section titled “Relationship endpoints”An entity whose module serves get_by_id and that has at least one relationship gets two more routes, for every relationship {rel}:
| Method | /api/tasks/{id}/relationships/{rel} | /api/tasks/{id}/{rel} |
|---|---|---|
GET | the linkage, with links.self and links.related | the related resource (or null), or the related resources in linkage order |
PATCH | set or clear a to-one, replace a to-many; 204 | — |
POST, DELETE | add or remove one identifier of a to-many; 204 | — |
DELETE /api/tasks/ship-the-emitter/relationships/tagsContent-Type: application/vnd.api+json
{ "data": [ { "type": "tags", "id": "codegen" } ] }- Relation fields are read through the module’s
get_by_idand written through itsupdate, with the same rules, hooks and errors as a resourcePATCHof that one field. Adding a member already present, or removing an absent one, writes nothing. A module with noupdateanswers every write403 relationship_update_unsupported, as doesPOSTorDELETEon a to-one. - Junction ops of the module (
list_Xwithadd_Yand/orremove_Y) define the to-many relationshipX.GETcallslist_X, paged in memory withpage[offset]andpage[limit]on a paginated surface.POSTandDELETEcalladd_Yandremove_Y, after alist_Xmembership check that makes a repeated add or remove a204that calls nothing.PATCHis403, as isPOSTwithoutadd_YandDELETEwithoutremove_Y. Such a relationship appears in resource objects withlinksonly. The build checks are in the Relationships guide. - One identifier per
POSTorDELETE; more is403 relationship_batch_unsupported, and{"data": []}is a no-op. - Related resources are read with the target module’s
get_by_id, which every target must serve. A dangling id is skipped. - An unknown
{rel}is404 no_such_relationship. A missing parent is the parent’s404, and a linked id that does not exist onPATCHorPOSTis404 no_such_related_resource.
Pagination support
Section titled “Pagination support”When you set pagination in your config, the List operations of the surface’s modules take page[offset] and page[limit] query parameters. pagination belongs to a surface, and the primary surface’s value covers every module on it, so every entity module of a single-surface crate pages (tasks-tracker pages task, epic and tag, and its build.rs lists the same three with .api_paginated(..) so each API list takes the page). A scoped list (route_prefix) pages exactly like an unscoped one: the page goes to the store and count supplies the total, not an in-memory slice. That holds for lists that are not served as resources too (see Operations that are not CRUD).
pagination: Some(ontogen::servers::PaginationConfig { default_limit: 50, max_limit: 200,}),GET /api/tasks?page[offset]=20&page[limit]=10{ "jsonapi": { "version": "1.1" }, "links": { "self": "/api/tasks?page%5Boffset%5D=20&page%5Blimit%5D=10", "first": "/api/tasks?page%5Boffset%5D=0&page%5Blimit%5D=10", "prev": "/api/tasks?page%5Boffset%5D=10&page%5Blimit%5D=10", "next": "/api/tasks?page%5Boffset%5D=30&page%5Blimit%5D=10", "last": "/api/tasks?page%5Boffset%5D=40&page%5Blimit%5D=10" }, "meta": { "total": 45, "limit": 10, "offset": 20 }, "data": [ /* 10 resource objects */ ]}page[limit]defaults todefault_limitand is silently clamped tomax_limit.page[offset]defaults to0.page[limit]=0, a value that is not a plain non-negative integer, and any otherpage[...]member (page[number],page[size]) are400 invalid_query_parameterwithsource.parameternaming the parameter.meta.totalis the count of the whole list;meta.limitandmeta.offsetare the effective values.self,firstandlastare always present.previsnullon the first page andnextisnullon the last. An offset at or past the end gives an emptydataand aprevlink to the last page.- Every link repeats the request’s
filter[...],sortandinclude, in one canonical order: the filters by member name, thensort,include,page[offset]andpage[limit], with the brackets percent-encoded and the effective page values. Equal requests get byte-equal links. limitandoffsetare not query parameters. Like any other unknown name they are400 invalid_query_parameter.
A list that is not paginated rejects page[...] with 400 invalid_query_parameter.
sort is honoured on a list that takes an order: &[OrderBy<{Entity}SortField>] argument: every generated list, and a hand-written one that declares it (see Filtered lists). The value is a comma-separated list of sort fields, applied in order. A bare name sorts ascending and a leading - descending. The id is the last key unless the request names it.
GET /api/tasks?sort=-created,title&page[limit]=10The page holds the tasks ordered by created descending, then title, then id. The links repeat sort between the filter and the page, so next keeps the order. The sort fields are the id and every scalar field of the entity: strings, numbers, bools, schema enums and optional forms of these. A body, a relationship and a dotted path are not sort fields. See Ordering for how nulls, strings and floats compare.
These are each 400 invalid_sort_field with source.parameter: "sort": an unknown field, a field named twice, an empty item (sort= or sort=title,,id), and any sort on a list that takes no order. The detail of an unknown field lists the sort fields ("`priority` is not a sort field of `tasks`; sort fields are: id, title, status, created"). A repeated sort parameter is 400 invalid_query_parameter.
Query parameters
Section titled “Query parameters”A generated route accepts a fixed set of query parameters. Anything else is 400 invalid_query_parameter with source.parameter naming it: an unknown name, a repeated name, a malformed name, fields[...], and page[...] on a route that is not paginated. [ and ] are accepted raw or percent-encoded. filter[...] is accepted on a list that takes filter parameters and refused on any other route. Relationship and related routes take no query parameters, except page[offset] and page[limit] for a junction op’s relationship on a paginated surface. include is honoured on a list and a get (see Compound documents); a bad value is 400 invalid_include_path with source.parameter: "include". sort is honoured on a list that takes an order argument (see Sort) and is 400 invalid_sort_field on any other list. A bad value of either is the code named above, not invalid_query_parameter.
Errors
Section titled “Errors”Every error the generated HTTP server returns is a JSON:API error document with one error object, whatever the route:
{ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", "code": "task_not_found", "title": "Not Found", "detail": "Task not found: nope" } ]}statusis the HTTP status as a string andtitleis its reason phrase.codeis stable anddetailis the human text (for anAppErrorit is the error’sDisplayoutput). Do not parsedetail.sourceis present for request problems and has one member:pointerinto the body,parameterfor a query parameter (with raw brackets,"page[limit]"), orheader.- The server reports the first failure it finds. Checks run in a fixed order: routing (
405),Accept(406),Content-Type(415), path parameters (a bad prefix value, an unknown{rel}), query parameters (unaccepted names first, thenfilter[...],sort,includeand the page), refusals decided by the route alone (the relationship403s), then the body (413for one over the size limit, then its members in schema order), then the parent and related-resource existence, then the store call. SoGET /api/tasks/nope?include=owneris400 invalid_include_path, not404, and aPATCHto a missing id with a malformed body gets the body’s400. The contract’s check order lists every step.
AppError mapping. The generator scans error_source_dir for enum AppError and maps each variant by the suffix of its name. The code is the variant name in snake_case.
| Variant | Status | Example code |
|---|---|---|
{Entity}NotFound | 404 | task_not_found |
{Entity}IdRequired | 400 | task_id_required |
{Entity}AlreadyExists | 409 | task_already_exists |
{Child}ParentRequired | 403 | task_parent_required |
| any other variant | 500 | db_error |
The generated store constructs {Entity}NotFound and {Entity}AlreadyExists for every entity, {Entity}IdRequired for every markdown entity and for a SeaORM entity whose id strategy is Provided or SlugFromField (a SeaORM entity under Uuid always gets an id), and {Child}ParentRequired for an entity that is the child of a has_many whose foreign key is not Option, so your AppError declares each one the store uses (tasks-tracker declares the first three for Task, Epic and Tag). An error mapped from AppError carries no source, except {entity}_already_exists on a create that sent data.id. Only an op whose error type is your AppError is mapped; any other error type is 500 internal_error.
When the build generates the HTTP server, a variant whose snake_case name equals one of ontogen’s own codes (for example an InvalidDocument or NoSuchRelationship variant) fails the build, so a code always means one thing; no ontogen code ends in a store suffix such as _not_found, so an entity’s store variants never clash. Two enum AppError declarations in the scanned directory also fail it. With no AppError in the directory, every AppError-typed site answers 500 internal_error.
Ontogen’s own codes. Errors raised by the generated server itself, independent of your AppError:
| Status | code | Raised when |
|---|---|---|
| 400 | invalid_query_parameter | an unknown, repeated or malformed query parameter, a bad page[...] or opArg[...] value, or any query parameter on a custom POST |
| 400 | invalid_sort_field | a bad sort: an unknown field, a field named twice or an empty item, or any sort on a list that takes no order |
| 400 | invalid_include_path | a bad include on a list or a get: an unknown, dotted, empty or non-includable relationship name |
| 400 | invalid_path_parameter | a typed route-prefix parameter fails to parse |
| 400 | invalid_document | the body is not JSON or not a valid resource document, or a custom op body whose meta.args is missing, unknown or ill-typed |
| 400 | unknown_attribute, invalid_attribute | an attribute the type does not have, or a value serde rejects (null on a non-Option field included), in a create or update body |
| 400 | missing_attribute | a required attribute absent from a create body |
| 400 | unknown_relationship | a relationship name the type does not have, in a create or update body |
| 400 | missing_relationship | a non-Option to-one absent from a create body |
| 403 | relationship_required | null for a non-Option to-one |
| 403 | relationship_update_unsupported | a relationship write the relationship does not support: POST or DELETE on a to-one, PATCH on a junction op’s relationship, POST without add_Y or DELETE without remove_Y, any write when the module has no update, or a junction op’s relationship in a create or update body |
| 403 | relationship_batch_unsupported | a relationship POST or DELETE with more than one identifier |
| 404 | no_such_related_resource | a linked id that does not exist |
| 404 | no_such_relationship | a {rel} that is not a relationship of the type |
| 405 | method_not_allowed | a method the route does not serve |
| 406 | not_acceptable | Accept cannot be satisfied |
| 409 | type_mismatch | a type that is not the endpoint’s, or a linked identifier whose type is not the relationship’s target |
| 409 | id_mismatch | a PATCH body id that differs from the URL id |
| 413 | content_too_large | the body exceeds the server’s body limit |
| 415 | unsupported_media_type | Content-Type is not acceptable |
| 500 | internal_error | a store-construction or scope-accessor failure, or any op or event subscribe whose error type is not AppError |
Axum’s own extractor rejections never reach the client in plain text: the generated handlers use the ontogen-jsonapi extractors, which produce these documents.
Operations that are not CRUD
Section titled “Operations that are not CRUD”Custom ops, and the junction and CRUD ops of a module with no entity behind it, are served as custom ops: documents with meta and no data.
Responses. A custom op answers 200 with meta.result holding the value the function returned, serialized by serde. It is a plain value even when it is an entity or a list of entities. A function that returns () answers 204 with no body. The status is never 201. There are no links.
GET /api/stats/workout{ "jsonapi": { "version": "1.1" }, "meta": { "result": { "total_count": 12, "total_duration_minutes": 540 } }}POST bodies. Every argument after the state and store is a member of meta.args, keyed by its Rust parameter name. That includes an *Input argument and Option arguments.
POST /api/workouts/startContent-Type: application/vnd.api+json
{ "meta": { "args": { "input": { "template_id": "push-day" } } } }- A request with no body is read as
{"meta": {"args": {}}}and needs noContent-Type. - A body that is not a JSON object is
400 invalid_document. A missingmetahas the pointer"", a missingmeta.argsthe pointer/meta, and ametaormeta.argsthat is not an object the pointer/metaor/meta/args. A function with no required arguments reads a missing member as{}. Other top-level members are ignored. - An unknown
meta.argsmember is400 invalid_documentat/meta/args/{name}. A missing required argument is400 invalid_documentat/meta/args. A value serde rejects is400 invalid_documentat its member. An absent ornullOptionargument isNone. - A custom
POSTaccepts no query parameters.
GET arguments. Required arguments are path segments after the action, as in GET /api/workouts/summary/{id}. Option arguments are opArg[name] query parameters: GET /api/workouts/summary/w1?opArg[verbose]=true. Values deserialize as serde does from a string. A bare name, an unknown opArg[…] or a malformed value is 400 invalid_query_parameter.
An *Input parameter on an op served without a body is a build error (CodegenError): an #[ontogen::http::get] op, get_by_id, delete, list_X and remove_Y.
Singletons. A singleton module (// ontogen:singleton) has no resource type, and every op in it is a custom op on /api/{singular}/{action}. An op there that classifies as list, get_by_id, create, update, delete or a junction op is a build error.
Modules with no entity behind them (for instance when gen_servers is called with an empty schema) serve their CRUD functions at the usual routes, under the custom op rules:
| Op | Route | Request | Response |
|---|---|---|---|
list | GET /api/{m} | filtered: filter[...]; paginated: opArg[limit], opArg[offset] | meta.result: the list, or {items, total, limit, offset} when paginated |
get_by_id | GET /api/{m}/{id} | none | meta.result |
create | POST /api/{m} | meta.args.input | 200, meta.result |
update | PATCH /api/{m}/{id} | meta.args.input | 200, meta.result |
delete | DELETE /api/{m}/{id} | none | 204 |
opArg[limit] and opArg[offset] are unsigned 32-bit integers. An absent limit is default_limit, a larger one is clamped to max_limit (opArg[limit]=0 is a zero-size page, not an error), and an absent offset is 0. A malformed value is 400 invalid_query_parameter. The page goes to the store’s page-taking list, and count supplies total.
Junction ops in a module with no entity behind it are custom ops at these routes, scoped or not. (In a module named after an entity they define a relationship instead; see Relationship endpoints.)
| Op | Route | Request | Response |
|---|---|---|---|
list_X | GET /api/{m}/{parent_id}/{children} | paginated: opArg[limit], opArg[offset] | meta.result: the list, or {items, total, limit, offset} |
add_Y | POST /api/{m}/{parent_id}/{children} | meta.args.{child param} | 204 |
remove_Y | DELETE /api/{m}/{parent_id}/{children}/{child_id} | none | 204 |
A paginated junction list slices the full list in memory.
Event streams are text/event-stream routes, and only the data: payload is JSON:API shaped. When the item type is a schema entity, data: is its resource object without links: type, id, attributes and relationships (data only, and left out when the entity has none). Any other item is {"meta": {"result": …}}. A lag frame stays {"skipped": n}. See Event subscriptions.
Filtered lists. A hand-written list that takes filter parameters (anything besides the page) is served like any list. In a module named after an entity it is GET /api/{type} with filter[...] and page[...], and meta.total comes from the module’s hand-written count, which takes the same filter. In a module with no entity behind it it is a custom op at GET /api/{module} with filter[...] and opArg[limit]/opArg[offset], answering meta.result. A filter is at most one *Query struct taken by value (each serde field is filter[field]) plus bare parameters (filter[name], optional when an Option). Its scoped variant behaves like its unscoped one. See the Filtered lists recipe.
Every error from these routes is the error document described under Errors.
Handler and router shape
Section titled “Handler and router shape”The generator also produces an entity_routes() function that wires all handlers to their routes. Each route installs a method fallback that answers 405 with Allow. The task routes of tasks-tracker’s router:
pub fn entity_routes() -> ::axum::Router<::std::sync::Arc<AppState>> { ::axum::Router::new() // ... routes of the other modules .route( "/api/tasks/{id}", ::axum::routing::get(task_get_by_id) .patch(task_update) .delete(task_delete) .fallback(ontogen_allow([OntogenMethod::GET, OntogenMethod::PATCH, OntogenMethod::DELETE])), ) .route( "/api/tasks", ::axum::routing::post(task_create) .get(task_list) .fallback(ontogen_allow([OntogenMethod::POST, OntogenMethod::GET])), ) .route( "/api/tasks/{id}/relationships/{rel}", ::axum::routing::get(ontogen_task_relationship_get) .patch(ontogen_task_relationship_patch) .post(ontogen_task_relationship_post) .delete(ontogen_task_relationship_delete) .fallback(ontogen_allow([ OntogenMethod::GET, OntogenMethod::PATCH, OntogenMethod::POST, OntogenMethod::DELETE, ])), ) .route( "/api/tasks/{id}/{rel}", ::axum::routing::get(ontogen_task_related_get).fallback(ontogen_allow([OntogenMethod::GET])), )}Handlers are named {module}_{op} (task_list, task_get_by_id, task_create, task_update, task_delete). The relationship routes are served by ontogen_task_relationship_get, …_patch, …_post and …_delete, and the related route by ontogen_task_related_get (with a _scoped suffix under a prefix). The file also holds two error helpers, ontogen_app_error (an AppError to its status and code) and ontogen_internal_error (every other failure, 500 internal_error).
The only bare names in the file are your types and API modules. Everything the file defines besides its handlers is prefixed (Ontogen… types, ontogen_… fns), and every item it uses from axum, ontogen-jsonapi, serde or std is imported under an Ontogen alias (OntogenDocument, OntogenState) or named by its ::-rooted path (::serde_json::Value, ::axum::routing::get). An entity may be called Document, Response, State or Serde, and an API module request, serde or match (written r#match). The ids a create or update document links are held under the relation fields’ names, so a relation field crate_id (the relationship crate) is served too.
Tauri IPC generation
Section titled “Tauri IPC generation”The IPC generator produces #[tauri::command] handlers for desktop applications. Each command follows an entity-first naming convention:
| API function | IPC command name |
|---|---|
task::list | task_list |
task::get_by_id | task_get_by_id |
task::create | task_create |
agent::add_role | agent_add_role |
task::get_overdue | task_get_overdue |
The command iron-log gets for workout::list:
#[::tauri::command]pub async fn workout_list( sort: Option<Vec<String>>, ontogen_state: ::tauri::State<'_, ::std::sync::Arc<AppState>>,) -> Result<Vec<Workout>, String> { let ontogen_order = ::ontogen_core::order::parse_sort(sort.unwrap_or_default()).map_err(|ontogen_e| ontogen_e.to_string())?; let ontogen_store = ontogen_state.store().await.map_err(|ontogen_e| ontogen_e.to_string())?; workout::list(&ontogen_store, &ontogen_order).await.map_err(|ontogen_e| ontogen_e.to_string())}A command names each parameter after the function’s argument, since Tauri reads each argument from the invoke payload under a key derived from that name: the name without its r#, lowerCamelCased with heck (project_id is projectId, _kind is kind, r#type is type). The command prefixes its own bindings with ontogen_. Payloads are flat: entities in and out with their Rust field names, relation fields as ids, PaginatedResult for a paginated list, and errors as strings (an AppError’s Display text).
A list that takes an order has an optional sort argument, an array of the keys sort takes over HTTP (['-created', 'title']). It is read with ontogen_core::order::parse_sort, so a bad key is the string error unknown sort field `priority` , and it is read before the store opens. A list’s filter and page arguments sit beside it. A bare filter named sort on such a list is a build error, since it would share the sort key.
The generator also produces an ipc_handler() function that wraps all commands into a tauri::generate_handler! macro call:
pub fn ipc_handler() -> impl Fn(::tauri::ipc::Invoke) -> bool + Send + Sync + 'static { ::tauri::generate_handler![ task_list, task_get_by_id, task_create, task_update, task_delete, // ... all commands ]}Wire this into your Tauri builder:
tauri::Builder::default() .invoke_handler(generated::ipc_handler()) .run(tauri::generate_context!())Event subscriptions
Section titled “Event subscriptions”An API function that returns broadcast::Receiver<T> is an event op. It may be async, may return Result<broadcast::Receiver<T>, E>, and may take parameters after the state:
pub async fn vault_changes( state: &AppState, vault_id: String, classes: Option<String>, resume: Option<String>,) -> Result<broadcast::Receiver<LoggedChange>, AppError>;-
HTTP. An SSE route. Required params are path segments and
Optionparams are plain query params (notopArg[…], which only customGETops use):GET /api/events/vault-changes/{vault_id}?classes=…. A subscribe that fails before the stream opens is an error document, mapped like any op: an error of typeAppErrorgets its status andcode, any other error type500 internal_error. -
Resume. A param named
resumeof typeOption<String>makes the op resumable. The handler fills it from theLast-Event-IDheader, or theresumequery param when the header is absent. The item type must implementontogen_core::events::EventSeq; itsevent_id()is written as each event’sid:. Ontogen does not replay history; the function decides what resuming means. -
Frames. Over HTTP the
data:payload is the entity’s resource object when the item type is a schema entity:type,id,attributesandrelationships, whose objects carrydataonly, with nolinksanywhere. Any other item is{"meta": {"result": …}}. A resumable op writes the item’sevent_id()asid:. iron-log’sactivity_for_kindyields anActivity, which is not an entity:event: activity-for-kinddata: {"meta":{"result":{"seq":4,"kind":"workout","id":"w1"}}}id: 4IPC frames are flat.
-
Lag. When a subscriber falls behind the broadcast, it receives an
event: lagframe withdata: {"skipped": n}and the stream stays open. -
IPC. Each event op generates
{fn}_subscribe(args…, channel)and{fn}_unsubscribe(id). Subscribe forwardsEventFrame<T>messages ({"kind":"event","id":…,"data":…}or{"kind":"lag","skipped":n}) into the caller’stauri::ipc::Channeland returns a subscription id. The forwarding task ends on unsubscribe, on the first failed send (the webview is gone), or when the senders close. Both commands are inipc_handler().
The generated code uses ontogen-core’s runtime support, so a crate with event ops depends on it with the events feature, plus tokio and futures:
ontogen-core = { version = "…", features = ["events"] }For the parameterless sync shape (fn f(state: &AppState) -> broadcast::Receiver<T>), the IPC generator also emits start_event_forwarding, which emits every such stream to all windows as a Tauri event. Prefer the subscribe commands: they report lag to the subscriber and end with it.
MCP generation
Section titled “MCP generation”The MCP (Model Context Protocol) generator produces tool definitions for AI assistants. Each API function becomes an MCP tool with a JSON Schema input definition, a description, and an async handler.
pub struct McpToolDef { pub name: &'static str, pub description: &'static str, pub schema_fn: fn() -> ::serde_json::Value, pub handler: OntogenHandlerFn,}The generated registry provides three entry points:
generated_tool_registry()— returnsVec<McpToolDef>with live handlers for tool execution.tool_definitions()— returnsVec<SimpleToolDef>with pre-computed schemas, suitable fortools/listresponses.handle_tool_call(state, tool_name, args)— dispatches a tool call by name. Creates a minimal tokio runtime for sync MCP servers.
MCP tool names follow the same entity-first convention as IPC commands: task_list, task_create, agent_add_role. A tool reads each argument under the name its input schema gives it, serde’s: an argument named after a Rust keyword (r#type) is type.
A list tool whose list takes an order has an optional sort property in its input schema, an array whose items are the sort keys (id, -id, title, -title, and so on, enumerated from the entity’s fields):
{ "sort": ["-created", "title"] }sort is read strictly: absent or null is the default order, and anything other than an array of strings is the tool error Invalid sort: …. A bad key gives the same text as IPC, and the tool reads sort before it opens the store. The *Query struct is read from the other arguments, so a field serialized as sort cannot be set over MCP, and a bare filter named sort is a build error.
Names in the generated files
Section titled “Names in the generated files”The IPC and MCP files bring into scope only your names: your API modules (r#match for a module named after a keyword), the types your functions name, and your state and store. Every runtime item is written by its ::-rooted path (::serde_json::Value, ::tauri::State, ::std::sync::Arc), so an entity may be named Value, State, Arc or Future, and a module named log does not shadow the log crate. The types a file defines itself carry the Ontogen prefix (OntogenHandlerFn, Ontogen{Module}{Fn}Input), except the ones consumers use: McpToolDef, SimpleToolDef, GetByIdInput, ByIntIdInput, EmptyInput and IPC’s PaginatedResult.
Names the generated files cannot keep apart fail the build with an error that names them: an API fn named with a raw identifier (r#type), an argument with no plain name (_), no letter or digit (__) or the ontogen_ prefix, a type your functions name that matches one of the names above, the state or store type, or starts with Ontogen, and, with MCP, an API module named std or schemars. The entity names the build refuses are listed under Minimal form.
The naming system
Section titled “The naming system”URL paths and command names are derived from module names using the cruet crate for Rails-style inflection. The NamingConfig lets you override the defaults when the inflector gets it wrong.
naming: ontogen::servers::NamingConfig { plural_overrides: HashMap::from([ ("evidence".to_string(), "evidence".to_string()), // uncountable ]), singular_overrides: HashMap::from([ ("work_sessions".to_string(), "session".to_string()), ]), label_overrides: Default::default(), plural_label_overrides: Default::default(), // Modules served at a singular URL, as `// ontogen:singleton` does. singleton_modules: HashSet::from(["database".to_string()]), // Per-function IPC command / TS method names, keyed `module::fn`. command_overrides: Default::default(),},The naming system provides several derived forms:
| Method | Input | Output | Used for |
|---|---|---|---|
url_plural | "work_session" | "work-sessions" | HTTP route paths |
url_singular | "work_sessions" | "session" | IPC command prefixes |
module_plural | "evidence" | "evidence" | Rust code references |
label | "work_session" | "Work Session" | Admin UI labels |
plural_label | "evidence" | "Evidence" | Admin UI plural labels |
For custom functions, derive_action strips the module name from the function name and converts to kebab-case: get_overdue_tasks on the task module becomes the action segment overdue.
SSE route overrides
Section titled “SSE route overrides”By default, event stream functions get routes under /api/events/{kebab-name}. You can override individual routes:
sse_route_overrides: HashMap::from([ ("graph_updated".to_string(), "/api/events/graph".to_string()),]),Route prefix for project scoping
Section titled “Route prefix for project scoping”When your app manages multiple projects, you can prepend a project scope to all entity routes:
route_prefix: Some(ontogen::servers::RoutePrefix { // Colon-style here -- see the note below. segments: "projects/:project_id".to_string(), state_accessor: "store_for".to_string(), params: vec![ ontogen::servers::PrefixParam { name: "project_id".to_string(), rust_type: "uuid::Uuid".to_string(), ts_type: "string".to_string(), }, ],}),This generates scoped routes like /api/projects/{project_id}/tasks alongside the entity handlers. Every kind of route exists under the prefix with the same shape and wire behaviour as its unscoped one: relationship routes (/api/projects/{project_id}/tasks/{id}/relationships/tags) wherever the entity’s scoped get_by_id exists, and the junction ops of a module with no entity at /api/projects/{project_id}/{m}/{parent_id}/{children}. The state_accessor method is called to construct a project-scoped Store from the extracted path parameter. Scoped CRUD routes speak JSON:API like unscoped ones, with the prefix in every link and Location; the resource type does not carry it. A prefix parameter that fails to parse is 400 invalid_path_parameter. A scoped list that is not served as a resource passes the page to the store’s list and calls count, like an unscoped one, so a hand-written scoped list must honour its limit and offset.
Generated file locations
Section titled “Generated file locations”Each transport writes to a single file specified in its config:
src/api/transport/ http/ generated.rs # Axum routes + entity_routes() ipc/ generated.rs # Tauri commands + ipc_handler() mcp/ generated.rs # MCP tools + tool_definitions() + handle_tool_call()All three files are regenerated on every build. Don’t edit them.