Trainora API, as shipped in the download
Trainora — API, webhooks and the agent door
Everything here runs on your own server. Nothing is proxied, and no request leaves your network unless you point it somewhere.
Authentication
Mint a key in the app: API & agents → API keys. Send it as a bearer token:
Authorization: Bearer trk_...
Each key has a scope:
| Scope | What it can do |
|---|---|
| Read and write | Everything the key's user could do in the browser. |
| Read only | GET, HEAD and OPTIONS. Anything else is refused with 403 forbidden at the door — not per route, because the route that forgets is the one that leaks. |
A key can never exceed its user's role. A read key belonging to an admin still cannot write.
Endpoints
| Method | Path | What it does | ||
|---|---|---|---|---|
GET | /api/ping | Verify a key; returns its scope. | ||
GET | /api/matrix | The whole grid: every person against every competency, with each cell's state and expiry date. | ||
GET | /api/attention | Expired, never recorded and expiring — worst first. `?state=expired\ | none\ | expiring`. |
GET | /api/subjects | The register of people. | ||
GET | /api/subjects/{id} | One person and what they hold. | ||
GET | /api/requirements | The competencies tracked, with their validity in months. | ||
GET | /api/records | Records, newest first. ?subject_id= ?requirement_id= | ||
POST | /api/records | Record a completion. | ||
GET | /api/openapi.json | OpenAPI 3 description of all of the above. | ||
GET | /healthz | Liveness, no key required. |
Record a completion
curl -X POST https://trainora.example.com/api/records \
-H "Authorization: Bearer trk_xxxx" -H "Content-Type: application/json" \
-d '{"subject_id": 4, "requirement_id": 2, "achieved_on": "2026-08-01"}'
You cannot set the expiry. It is computed from the competency's own validity, because a client that could choose its own expiry date would make the whole register meaningless. The same call refuses, with a sentence, when:
- the date is in the future, or is not a real date;
- the competency does not apply to that person's team or role;
- the person or the competency is archived, or does not exist.
These are the same refusals the grid shows and the MCP tool returns, because all four doors call one function.
Webhooks
record.created fires whenever a completion is recorded, from any door. Configure receivers under API & agents. One delivery attempt per event, with a log — that is a log, not a retry queue.
Limits, honestly
- List endpoints cap at 500 rows. This is a compliance register for one organisation, not a warehouse.
- There is no endpoint that creates a person, a competency or a session. Those are deliberate screens with their own guards; the API is for reading the register and recording completions.
- No endpoint issues a certificate. Trainora does not issue certificates — see the hand-off below.
The certificate hand-off
GET /export/certora-recipients.csv emits exactly name,course,email, one row per person who currently holds something that has not expired. Those three header names are [Certora](../29-certora)'s recipient-import contract; import the file there and issue from it.
It is a file hand-off on purpose: no request leaves this box, and Trainora never learns whether a certificate was issued. If you want that link automated, drive Certora's own API with this file.
MCP — the agent door
Trainora speaks the Model Context Protocol at POST /mcp, so Claude, ChatGPT agents, n8n's AI nodes or your own code can use the register rather than merely read it. It is the same product underneath: the same bearer key, the same roles, and the same expiry engine. An agent cannot do anything its key's user could not do in the browser — and it cannot compute an expiry date, ever.
Transport is streamable HTTP, stateless: one JSON-RPC 2.0 request in, one JSON response out.
Connect it to an assistant
Mint the key in the app first: API & agents → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.
Every client needs the same three facts, and nothing in the handshake is vendor-specific:
| The address | https://trainora.example.com/mcp |
| The key | header Authorization: Bearer trk_xxxx |
| The transport | MCP over streamable HTTP, stateless |
Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:
claude mcp add --transport http trainora https://trainora.example.com/mcp \
--header "Authorization: Bearer trk_xxxx"
ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):
{
"type": "mcp",
"server_label": "trainora",
"server_url": "https://trainora.example.com/mcp",
"authorization": "trk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "trainora", "name": "Trainora", "url": "https://trainora.example.com/mcp",
"token": "trk_xxxx", "enabled": true }
]
}
**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.
A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:
curl -X POST https://trainora.example.com/mcp \
-H "Authorization: Bearer trk_xxxx" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Trainora implements the protocol, not an integration with a particular vendor.
Tools
| Tool | Reads or writes | What it is for |
|---|---|---|
matrix | reads | The whole grid, with coverage. |
attention | reads | What is expired, missing or expiring — the list to work down. |
subject_detail | reads | One person: what they hold and what they are missing. |
requirements | reads | The competencies and how long each stays valid. |
record_completion | writes | Record that somebody met a competency on a date. |
Every read tool is annotated readOnly, which is what makes a read-scoped key see only those four. A read key calling record_completion by name gets a refusal naming the tool, not a silent failure.