Skip to content

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.

TransportProtocolOutputUse case
HTTP/AxumREST over HTTPAxum route handlers + router functionWeb APIs, external integrations
Tauri IPCInter-process communicationTauri #[command] handlers + ipc_handler()Desktop apps with Tauri
MCPModel Context ProtocolTool 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.

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:

FieldPurpose
api_dirDirectory to scan for API source files
state_typeYour AppState type name, used in handler signatures
service_import_pathRust import path for API modules (e.g., crate::api::v1)
types_import_pathImport path for schema/DTO types
state_importFull import path for the state type
namingPluralization and URL naming overrides
generatorsWhich server transports to generate
store_typeStore type name for entity-scoped functions
store_importFull import path for the Store type
route_prefixOptional scope prepended to every route — see Route prefix
paginationOptional pagination for list operations
error_source_dirDirectory 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.

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.

Each OpKind maps to a specific HTTP method and path pattern:

OperationMethodRoute patternExample
ListGET/api/{entities}GET /api/tasks
GetByIdGET/api/{entities}/{id}GET /api/tasks/{id}
CreatePOST/api/{entities}POST /api/tasks
UpdatePATCH/api/{entities}/{id}PATCH /api/tasks/{id}
DeleteDELETE/api/{entities}/{id}DELETE /api/tasks/{id}
CustomGetGET/api/{entities}/{action}GET /api/tasks/overdue
CustomPostPOST/api/{entities}/{action}POST /api/tasks/close-by-project
RelationshipGET, PATCH, POST, DELETE/api/{entities}/{id}/relationships/{rel}POST /api/tasks/{id}/relationships/tags
Related resourceGET/api/{entities}/{id}/{rel}GET /api/tasks/{id}/epic
JunctionList, no entityGET/api/{m}/{parent_id}/{children}GET /api/agents/{id}/roles
JunctionAdd, no entityPOST/api/{m}/{parent_id}/{children}POST /api/agents/{id}/roles
JunctionRemove, no entityDELETE/api/{m}/{parent_id}/{children}/{child_id}DELETE /api/agents/{id}/roles/{child_id}
EventStreamGET (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.

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 relationship DELETE; for a custom op, only when it sends one) must send Content-Type: application/vnd.api+json. Anything else, including application/json or no Content-Type at all, is 415 unsupported_media_type with source.header: "Content-Type". An ext parameter, or any parameter other than profile, is also 415. A body on a GET or a resource DELETE is ignored.
  • A missing Accept is acceptable. When Accept lists application/vnd.api+json, one of those entries must have no parameter other than profile and q, and a non-zero q; otherwise the request is 406 not_acceptable, even when */* is listed too. When it does not list it, */* or application/* satisfies it, and anything else (text/html alone) is 406. A 406 carries source.header: "Accept".
  • 204 responses have no body and no Content-Type.
  • [ and ] in query parameter names are accepted raw or percent-encoded. Links the server writes always percent-encode them.

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" }
}
}
  • id is the entity’s #[ontology(id)] field, which must be a String: a build that generates the HTTP server or an HTTP client refuses any other id type.
  • attributes holds every other non-relation field under its Rust field name. Option fields that are None are null.
  • Each relation field becomes a relationship. A belongs_to field named epic_id becomes epic; other relation fields keep their name. A relationship object carries links to its relationship endpoints and its data. The relationships of the module’s junction ops follow the relation fields, with links only. When the module serves no get_by_id there are no relationship endpoints, and relationship objects carry data only. A type with no relationship has no relationships member. See Relationships for each kind’s linkage.
  • Links are relative paths. Ids in links are percent-encoded.
  • A module that serves no get_by_id has no route at /api/{type}/{id}, so its resource objects carry no links, a create sends no Location, and its create and update documents have no top-level links.
OperationSuccess
List200, data is an array ([] when empty), in sort order, or by id ascending without one
GetById200, data is one resource; a missing id is the store’s 404 {entity}_not_found
Create201, Location header equal to links.self (when the module serves get_by_id), data is the created resource
Update200, data is the full updated resource
Delete204, 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.id is 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 ., not index or log, and not a Windows device name. Anything else is 400 invalid_document at /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 is 400 {entity}_id_required.
  • A client id that already exists is 409 {entity}_already_exists, with source.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.

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 of included and stays in the linkage.
  • A field relationship can be included when its target module serves get_by_id in 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 are 400 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.

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}
GETthe linkage, with links.self and links.relatedthe related resource (or null), or the related resources in linkage order
PATCHset or clear a to-one, replace a to-many; 204—
POST, DELETEadd or remove one identifier of a to-many; 204—
DELETE /api/tasks/ship-the-emitter/relationships/tags
Content-Type: application/vnd.api+json
{ "data": [ { "type": "tags", "id": "codegen" } ] }
  • Relation fields are read through the module’s get_by_id and written through its update, with the same rules, hooks and errors as a resource PATCH of that one field. Adding a member already present, or removing an absent one, writes nothing. A module with no update answers every write 403 relationship_update_unsupported, as does POST or DELETE on a to-one.
  • Junction ops of the module (list_X with add_Y and/or remove_Y) define the to-many relationship X. GET calls list_X, paged in memory with page[offset] and page[limit] on a paginated surface. POST and DELETE call add_Y and remove_Y, after a list_X membership check that makes a repeated add or remove a 204 that calls nothing. PATCH is 403, as is POST without add_Y and DELETE without remove_Y. Such a relationship appears in resource objects with links only. The build checks are in the Relationships guide.
  • One identifier per POST or DELETE; more is 403 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} is 404 no_such_relationship. A missing parent is the parent’s 404, and a linked id that does not exist on PATCH or POST is 404 no_such_related_resource.

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 to default_limit and is silently clamped to max_limit. page[offset] defaults to 0.
  • page[limit]=0, a value that is not a plain non-negative integer, and any other page[...] member (page[number], page[size]) are 400 invalid_query_parameter with source.parameter naming the parameter.
  • meta.total is the count of the whole list; meta.limit and meta.offset are the effective values.
  • self, first and last are always present. prev is null on the first page and next is null on the last. An offset at or past the end gives an empty data and a prev link to the last page.
  • Every link repeats the request’s filter[...], sort and include, in one canonical order: the filters by member name, then sort, include, page[offset] and page[limit], with the brackets percent-encoded and the effective page values. Equal requests get byte-equal links.
  • limit and offset are not query parameters. Like any other unknown name they are 400 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]=10

The 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.

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.

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"
} ]
}
  • status is the HTTP status as a string and title is its reason phrase. code is stable and detail is the human text (for an AppError it is the error’s Display output). Do not parse detail.
  • source is present for request problems and has one member: pointer into the body, parameter for a query parameter (with raw brackets, "page[limit]"), or header.
  • 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, then filter[...], sort, include and the page), refusals decided by the route alone (the relationship 403s), then the body (413 for one over the size limit, then its members in schema order), then the parent and related-resource existence, then the store call. So GET /api/tasks/nope?include=owner is 400 invalid_include_path, not 404, and a PATCH to a missing id with a malformed body gets the body’s 400. 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.

VariantStatusExample code
{Entity}NotFound404task_not_found
{Entity}IdRequired400task_id_required
{Entity}AlreadyExists409task_already_exists
{Child}ParentRequired403task_parent_required
any other variant500db_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:

StatuscodeRaised when
400invalid_query_parameteran unknown, repeated or malformed query parameter, a bad page[...] or opArg[...] value, or any query parameter on a custom POST
400invalid_sort_fielda bad sort: an unknown field, a field named twice or an empty item, or any sort on a list that takes no order
400invalid_include_patha bad include on a list or a get: an unknown, dotted, empty or non-includable relationship name
400invalid_path_parametera typed route-prefix parameter fails to parse
400invalid_documentthe body is not JSON or not a valid resource document, or a custom op body whose meta.args is missing, unknown or ill-typed
400unknown_attribute, invalid_attributean attribute the type does not have, or a value serde rejects (null on a non-Option field included), in a create or update body
400missing_attributea required attribute absent from a create body
400unknown_relationshipa relationship name the type does not have, in a create or update body
400missing_relationshipa non-Option to-one absent from a create body
403relationship_requirednull for a non-Option to-one
403relationship_update_unsupporteda 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
403relationship_batch_unsupporteda relationship POST or DELETE with more than one identifier
404no_such_related_resourcea linked id that does not exist
404no_such_relationshipa {rel} that is not a relationship of the type
405method_not_alloweda method the route does not serve
406not_acceptableAccept cannot be satisfied
409type_mismatcha type that is not the endpoint’s, or a linked identifier whose type is not the relationship’s target
409id_mismatcha PATCH body id that differs from the URL id
413content_too_largethe body exceeds the server’s body limit
415unsupported_media_typeContent-Type is not acceptable
500internal_errora 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.

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/start
Content-Type: application/vnd.api+json
{ "meta": { "args": { "input": { "template_id": "push-day" } } } }
  • A request with no body is read as {"meta": {"args": {}}} and needs no Content-Type.
  • A body that is not a JSON object is 400 invalid_document. A missing meta has the pointer "", a missing meta.args the pointer /meta, and a meta or meta.args that is not an object the pointer /meta or /meta/args. A function with no required arguments reads a missing member as {}. Other top-level members are ignored.
  • An unknown meta.args member is 400 invalid_document at /meta/args/{name}. A missing required argument is 400 invalid_document at /meta/args. A value serde rejects is 400 invalid_document at its member. An absent or null Option argument is None.
  • A custom POST accepts 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:

OpRouteRequestResponse
listGET /api/{m}filtered: filter[...]; paginated: opArg[limit], opArg[offset]meta.result: the list, or {items, total, limit, offset} when paginated
get_by_idGET /api/{m}/{id}nonemeta.result
createPOST /api/{m}meta.args.input200, meta.result
updatePATCH /api/{m}/{id}meta.args.input200, meta.result
deleteDELETE /api/{m}/{id}none204

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.)

OpRouteRequestResponse
list_XGET /api/{m}/{parent_id}/{children}paginated: opArg[limit], opArg[offset]meta.result: the list, or {items, total, limit, offset}
add_YPOST /api/{m}/{parent_id}/{children}meta.args.{child param}204
remove_YDELETE /api/{m}/{parent_id}/{children}/{child_id}none204

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.

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.

The IPC generator produces #[tauri::command] handlers for desktop applications. Each command follows an entity-first naming convention:

API functionIPC command name
task::listtask_list
task::get_by_idtask_get_by_id
task::createtask_create
agent::add_roleagent_add_role
task::get_overduetask_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!())

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 Option params are plain query params (not opArg[…], which only custom GET ops 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 type AppError gets its status and code, any other error type 500 internal_error.

  • Resume. A param named resume of type Option<String> makes the op resumable. The handler fills it from the Last-Event-ID header, or the resume query param when the header is absent. The item type must implement ontogen_core::events::EventSeq; its event_id() is written as each event’s id:. 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, attributes and relationships, whose objects carry data only, with no links anywhere. Any other item is {"meta": {"result": …}}. A resumable op writes the item’s event_id() as id:. iron-log’s activity_for_kind yields an Activity, which is not an entity:

    event: activity-for-kind
    data: {"meta":{"result":{"seq":4,"kind":"workout","id":"w1"}}}
    id: 4

    IPC frames are flat.

  • Lag. When a subscriber falls behind the broadcast, it receives an event: lag frame with data: {"skipped": n} and the stream stays open.

  • IPC. Each event op generates {fn}_subscribe(args…, channel) and {fn}_unsubscribe(id). Subscribe forwards EventFrame<T> messages ({"kind":"event","id":…,"data":…} or {"kind":"lag","skipped":n}) into the caller’s tauri::ipc::Channel and 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 in ipc_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.

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() — returns Vec<McpToolDef> with live handlers for tool execution.
  • tool_definitions() — returns Vec<SimpleToolDef> with pre-computed schemas, suitable for tools/list responses.
  • 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.

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.

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:

MethodInputOutputUsed 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.

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()),
]),

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.

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.