Smithable websitedocsGitHub
Menu

Let other programs use your app

An app can offer its records to other programs: a monitoring tool that reports an outage, a spreadsheet that reads this month's invoices, an automation that resolves an incident. They call the app's API with a key. A key works with a role, like a person: it may do exactly what that role may, and nothing more.

This page uses the status starter, which offers its components and incidents and the resolve action. Every call below was made for real against a new status app, and the answers are shown as they came back (shortened where noted).

1. Say what the API offers

Nothing is offered until the app says so. The status starter already has this in smithable.auth.yml:

api:
  models: [Component, Incident]
  actions: [resolve]

For your own app, the operation sets it:

smithable op setApi Component Incident --actions resolve

setApi all offers every model, also those added later (--except leaves some out), and setApi with nothing turns the API off. Offering a model is not giving it away: what a key may do with it is still its role's access.

2. Make a key

Keys are made in the running app, by an administrator, on the users page (the status starter's Team page): what the key is for, its role, and an optional end date. The key is shown once; copy it then.

The Keys part of the team page: a key for "Uptime monitor" with the role Staff, shown once with a Copy button

Give a key the smallest role that does the job. Here Staff may report and resolve incidents but not manage the team. A key owns nothing it creates, can never make keys or manage people (whatever its role), and stops working when it is revoked, when its end date passes, or when the administrator who made it is removed. Every key made or revoked is in the app's activity log, by name, never the key itself.

3. Call the API

Send the key in the Authorization header. The app at https://status.example.com stands for yours (in development, http://localhost:3000).

KEY=smk_…   # the key you copied
curl -s https://status.example.com/api/v1/components?limit=2 -H "Authorization: Bearer $KEY"
{
  "items": [
    { "id": 3, "name": "Mobile app", "status": "operational", "description": "The app for iOS and Android.", "createdAt": "2026-10-04T06:53:17.287Z", "updatedAt": "2026-10-04T06:53:17.287Z" },
    { "id": 2, "name": "Payments", "status": "degraded", "description": "Card payments at checkout.", "createdAt": "2026-10-04T06:53:17.287Z", "updatedAt": "2026-10-04T06:53:17.287Z" }
  ],
  "page": 1,
  "pages": 2,
  "total": 3
}

A record's lists (incidents on a component, updates on an incident) are not fields of the record: they are the other model's records that refer to it, so they are read from that side, GET /api/v1/incidents?component=3, a page at a time like any list. A list comes a page at a time: limit (at most 100) and page. Fields have the names they have in the spec; amounts come as decimal texts ("12.50") and times as ISO texts in UTC.

Report an incident (a monitoring tool would do this):

curl -s -X POST https://status.example.com/api/v1/incidents \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"title": "Checkout is slow", "status": "investigating", "detectedAt": "2026-10-04T09:30:00Z", "component": 1}'
{ "id": 3, "title": "Checkout is slow", "status": "investigating", "component": 1, "detectedAt": "2026-10-04T09:30:00.000Z", "resolvedAt": null, "summary": null, "createdAt": "…", "updatedAt": "…" }

It answers 201 with the new record. A reference (component) is the other record's id. PATCH /api/v1/incidents/3 changes only the fields it is given, and DELETE removes the record, each as the key's role may.

Run an action: the same button a person presses, with the same checks.

curl -s -X POST https://status.example.com/api/v1/incidents/3/actions/resolve -H "Authorization: Bearer $KEY"
{ "id": 3, "title": "Checkout is slow", "status": "resolved", "resolvedAt": "2026-10-04T06:54:25.856Z", "…": "…" }

Without a key

What the app lets anyone read or add (public in smithable.auth.yml) can be called without a key, as a visitor. A status page that shows its incidents to everyone lets a program list and read them too: curl -s https://status.example.com/api/v1/incidents. Changing, deleting and actions always need a key, and so does everything when nothing is public. Without a key, the description at /api/v1/openapi.json and the tools at /mcp show only what a visitor may do.

Filter, search, sort and add up

A list takes the list page's filters as query parameters, behind the same access:

curl -s "https://status.example.com/api/v1/incidents?status=investigating,identified" -H "Authorization: Bearer $KEY"
curl -s "https://status.example.com/api/v1/incidents?component=Payments&sort=-detectedAt" -H "Authorization: Bearer $KEY"
curl -s "https://status.example.com/api/v1/incidents?detectedAt=2026-10-01..2026-10-31&search=payment" -H "Authorization: Bearer $KEY"

As a spreadsheet

The same list as CSV: add .csv to its address, with the same filters, search and sort. Every record that fits comes at once (at most 10,000), no paging:

curl -s "https://status.example.com/api/v1/incidents.csv?status=resolved&sort=-detectedAt" -H "Authorization: Bearer $KEY" -o incidents.csv

The first line has the fields' labels as the app shows them; amounts are decimals (12.50), dates and times ISO, a reference is its record's title and a choice its label; pictures and documents are left out. The file is UTF-8 with a byte-order mark, so a spreadsheet reads the letters right; a cell that starts like a formula (=, +, -, @) is written with a ' in front, so it stays text. In the app itself, "Export CSV" under a list saves the list as it is filtered, and the selection bar's saves the chosen records; neither needs the API.

The trash

A model with trash: true keeps a deleted record for thirty days (design/trash.md). DELETE /api/v1/<models>/{id} moves it to the trash (204, as for any delete). Reading it then answers 410 Gone, with when it was deleted, by whom, when it goes for good and where to restore it:

curl -s -X DELETE "https://crm.example.com/api/v1/customers/12" -H "Authorization: Bearer $KEY"
curl -s "https://crm.example.com/api/v1/customers/12" -H "Authorization: Bearer $KEY"
# {"status":410,"title":"This customer is in the trash.","deletedAt":"…","deletedBy":"…","removedOn":"…","restore":"/api/v1/customers/12/restore"}
curl -s -X POST "https://crm.example.com/api/v1/customers/12/restore" -H "Authorization: Bearer $KEY"

4. When the app says no

A refusal is a JSON problem (application/problem+json) in the app's own words, with the HTTP status that fits:

What happened Status The answer
No key for what needs one, or a revoked, expired or wrong key 401 {"status": 401, "title": "A valid key is needed: Authorization: Bearer <key>."}
Resolving an incident that is already resolved 409 {"status": 409, "title": "This cannot be done now."}
A field the record does not have 400 {"status": 400, "title": "There is no field \"colour\" to set. Fields: title, status, component, detectedAt, resolvedAt, summary."}
A value that does not fit (a missing title, an unknown status) 422 the fields and what is wrong with each, under errors
What the key's role may not do 403 {"status": 403, "title": "You are not allowed to do this."}
A record that is not there, or not one the key may read 404 {"status": 404, "title": "No such incident, or not one this key may read."}
More than 60 calls in a minute with one key 429 wait, and try again

5. What the API looks like, for tools

/api/v1/openapi.json describes every path, field and refusal in the OpenAPI 3.1 format, and needs no key to read. Tools that understand OpenAPI (Postman, n8n's HTTP Request node, an AI agent) can read it to learn what the app offers. For the status starter its paths are:

/api/v1/components
/api/v1/components/{id}
/api/v1/incidents
/api/v1/incidents/{id}
/api/v1/incidents/{id}/actions/resolve

6. For AI assistants

With assistants: true in the api block (the status starter has it), the same offer is also there as tools for AI assistants, at /mcp in the Model Context Protocol, with the same keys. Point an assistant that speaks MCP over HTTP (Claude, ChatGPT and others) at https://status.example.com/mcp with the header Authorization: Bearer <key>.

The tools are the same seven for every app, whatever its size:

Tool What it does
find Records of one kind, with where, search, sort, the fields wanted, a limit (10 unless said, 50 at most) and the total, as { columns, rows, total }
measure A number worked out by the app: how many, or the sum, average, min or max of a number field ("of": "sum total"), optionally by a field or by month
read One record with all its fields
add Add a record
change Change fields of a record
run One of the app's standard actions on a record
describe What a kind of record means: its fields, rules and actions

With the tools comes a short summary of the app, one line per kind of record, as the server's instructions; an assistant reads it once and asks for describe only when it needs more. Each tool is marked as reading only or as changing what exists, so an assistant that asks its user before a risky call knows which. The key's role decides what is offered: a narrow key is not told of what it cannot reach, and without a key an assistant gets what anyone may read or add (§3). There is a delete tool only when the auth file says assistants: { delete: true }, and only for a role that may delete.

A refusal comes back as the tool's error, in the app's words, so the assistant can read it and try again:

{ "content": [{ "type": "text", "text": "There is no field \"colour\" to filter by. Fields: title, status, component, detectedAt, resolvedAt, summary." }], "isError": true }

Each request carries one message (no batches), so every tool call counts toward the key's 60 a minute.

Each call is in the app's access log under the key (key:<n>) in place of a person, and every change it makes is in the activity log like anyone's.