MapJet, SendJet, and AuthJet are featured apps. Each is an ordinary app in a separate account on the same platform. Every jet is available to every account without an enable step. An account without a plan at a jet uses that jet's default plan.
A jet is used by an app. Each app that uses a jet has keys for it (jet.key.create). The jet receives the calling
app in request.auth.app and keeps its data and settings separate from other apps. The app's dashboard shows the
jet's menu items, such as AuthJet's Users, SendJet's Emails, and MapJet's Maps, alongside its pages. A function can
also call a jet's entries as its app without a key; see App calls.
The account's apps share its plan limit at the jet. For example, two apps on a plan allowing 1,000 requests a day share those 1,000 requests. Once the shared limit is reached, either app's next request is refused, even if that app has made no calls yet. Usage is also counted per app, so the console shows both the account total and each app's usage.
In the console the account's jets are listed by title in the sidebar's Substrio section and on
/a/{account}/featured; each opens its admin at its admin path under the account in view
(/a/{account}/featured/mapjet). Each app's own sidebar lists, under each jet's title, the menu items that jet
declares (/t/{app}/jets/{jet}/{item}).
A jet's site links to /app/?app=<jet> for Sign In, and to /app/?signup&app=<jet> for Start Free, where <jet> is
the word after /featured/ in its admin path. A person signs in once at AuthJet, and that login covers every jet.
Every jet has a dashboard at the jet's address. The console is built into the jet's site at /app/, and the first
label of the address names the product, because that label is the app's slug. A customer at mapjet.net/app/ sees
MapJet's name, MapJet's pages, Team, and Billing. There is no Substrio menu to learn. The last line of the menu links
to the whole Substrio dashboard at substrio.com/app/. A new jet needs only that slug and the console in the site
(ops/scripts/lead/console-into-site.sh).
A new customer needs no setup. The first arrival calls jet.start with no app named. The platform makes a project
for an account with no apps, and the key shows once. An account that already holds apps chooses one as before. AuthJet
works on the apps it signs people in for, so it makes no project.
A person signed in on a jet's site sees Dashboard in place of Sign In on that site, and Start Free goes away from the header and the footer. The site reads one key in the browser and makes no request. A person who is signed in at AuthJet and new to that site sees Sign In, and that click opens the dashboard with no form. A jet on the site kit needs nothing for this.
What a call costs
The key's scope admits the call and identifies the app in the table entry already read during admission, without
a second lookup. The jet charges the account's plan with substrio.usage.consume.
The account count determines admission, using one slot per counter and period and one compare-and-swap. When an accepted call comes from an account's app, through an app key or function, it also updates the app's slot. That adds one hash, one probe, and one add on one cache line. The app slot is not read to decide admission. The account slot already contains the total, so admission remains O(1) without summing apps. A refused call is not counted.
The consume operation stores counts in the mapped platform usage file (<data>/_usage/usage.slots). Its
compare-and-swap writes the slot there. Each accepted consume updates the persisted count.
Slots from past days or months are reused. The file grows with the accounts and apps
counted at once. If the disk is full and the file cannot grow, the consume is refused with reason "no_space".
If an app count cannot be stored, the account count is rolled back with it.
A Substrio plan can bundle jets: its apps block in the identity app's plans file names a plan at each.
Moving the account onto it writes the account's plan record at each jet; moving off it
puts the account back on each jet's default plan. Every app of the account is on that one plan.
One plan, and three kinds of users
An account has one Substrio plan, and Billing is the one place it changes. That plan sets every jet to the same tier, so Scale means Scale at SendJet, AuthJet, MapJet, and ReplyJet. An account can also buy one jet's plan on top, and the higher of the two applies at that jet. Each jet's page says which plan covers it.
| Who | What they are | Where they are managed |
|---|---|---|
| Your team | The members of a Substrio account | The account's Members page |
| Your app's users | The users who sign in to one app. Each app holds its own users. | The app's Users and access page |
| Your subscribers | Whoever holds a plan of an app you sell. A jet is an app, so its subscribers are listed the same way. | The Subscribers view on the app's Users and access page |
In the console
/a/{account}/featured lists every jet for the account in view: what it does, the plan the account is metered
against there, and the account's use this day and month (every app summed) against that plan's limits. A jet's
admin opens at its admin path, every tab read from the one featured.list entry:
| Jet | Path | Tabs |
|---|---|---|
| MapJet | /a/{account}/featured/mapjet |
Overview (use against the plan, every plan side by side), Keys (secret and publishable keys of the account's apps scoped to MapJet, each naming its app), Playground (each route run as the account's first app), API (the host, the headers, a call a route), Contact requests (only for the account that owns MapJet) |
| AuthJet | /a/{account}/featured/authjet |
Overview (plus the apps signing in), Apps (each app of the account as a sign-in client) |
| SendJet | /a/{account}/featured/sendjet |
Overview and Keys (every jet's, below) |
| Any other jet | /a/{account}/featured/<jet> |
Overview and Keys, the pages every jet has |
Every jet also has one page inside each app, /t/{app}/jets/{jet}/setup, and that page holds everything the jet
needs from the app. The app's sidebar lists it under Substrio apps, or as Set up under a jet's menu. There is no
enable step anywhere.
| Jet | What the page holds |
|---|---|
| AuthJet | Sign-in on or off, the redirect URIs, the client ID, the issuer, the code to add to the site, the app's logins, and the sign-in settings |
| SendJet | This app's keys, and a link to the SendJet dashboard at sendjet.io/app |
| Any other jet | This app's keys and this app's use, with no console release |
Each app's sidebar adds, under each jet's title, the items of that jet's menu (app_nav, below). Each item opens
/t/{app}/jets/{jet}/{item}: a list, a form or a detail drawn from the jet's own declaration, every route called
as that app with a key of the app at the jet (made the first time, a test key of 1,200 requests a minute named
"Console preview", kept for the browser tab). A jet adds or changes a page with a plans.set, with no console
release.
Purchase a jet's paid plan, alone or as part of a Substrio plan, on the console billing page, /a/{account}/billing.
The operations
A request names a jet with jet, such as the path's {jet} or a cart item's jet. Over MCP, app routes a call
to an app. Responses list each jet as app, while slug identifies the account's app a key belongs to.
Each is one entry of the operation registry, so each is also an HTTP route, an MCP tool, a CLI command
(substrio op <name>) and an SDK call.
| Operation | Route | Who | What it does |
|---|---|---|---|
featured.list |
GET /v1/_accounts/{id}/featured |
any member, the account's key | Every jet: title, summary, admin, the plan the account is metered against with its limits, the account's usage per counter this day and month (every app summed), owned (the account is the jet's own), its counters and every plan it offers (plans, each as the plans file writes it) |
jet.list |
GET /v1/_accounts/{id}/apps/{slug}/jets |
any member, the account's key | Every jet as the account's app {slug} has it: what featured.list answers, plus app_nav (the jet's menu in this app's dashboard), app_usage (this app's own use per counter this day and month), and hosted (what the jet keeps in this app, when it declares anything) |
jet.key.create |
POST /v1/_accounts/{id}/apps/{slug}/jets/{jet}/keys {"name", "mode"?, "origins"?, "requests_per_minute"?, "request_budget"?} |
an admin, the account's key | Makes a key of the account's app {slug} scoped to that one jet, the secret returned once. With origins (up to 8, https://host[:port]) the key is publishable: safe in a page's source, answering only those origins. Removing the app revokes the key |
From the CLI, the two jet operations run by their words: substrio jet list id=7 slug=shop and
substrio jet key create id=7 slug=shop jet=mapjet name=web.
Starting with a jet
jet.start takes you to a first working call in one step. It uses or makes the app, makes a key, and answers with what
the call needs.
| Operation | Route | Who | What it does |
|---|---|---|---|
jet.start |
POST /v1/_accounts/{id}/jets/{jet}/start {"app"?, "new_app"?, "name"?, "mode"?} |
an admin, the account's key | Uses the app named in app, or makes the app named in new_app, and makes a key of that app scoped to {jet}. The answer is 201 with app, url, endpoint, key (shown once), and next. Without app or new_app, the account's one app is used. An account with no app gets a new app named for the account. An account with several apps gets APP_REQUIRED, with their slugs in apps. |
curl -s -X POST $SUBSTRIO_URL/v1/_accounts/7/jets/sendjet/start \
-H "Authorization: Bearer $SUBSTRIO_KEY" -d '{"app": "shop"}'
{"app": "shop", "url": "https://shop.substrio.com", "endpoint": "https://shop.substrio.com/v1/_jets/sendjet", "key": "<shown once>",
"next": "Send requests to the endpoint with this key as a Bearer token. The key is shown once, so store it now."}
From the CLI, substrio jet use sendjet makes the same call. It uses the project's app unless --app or --new-app
says otherwise, and it writes the key to .env.sendjet as SENDJET_KEY, readable by you alone. --env FILE names
another file, and --force replaces a file that exists.
Over MCP the tool is substrio_jet_start. As with jet.key.create, an agent session waits for the owner's approval.
A refused call makes no app. If the key cannot be made after a new app was created, the app stays, so call jet.start
again with app.
An account's own (unscoped) key may make a jet key, since what it makes is narrower than itself. A scoped key
never reaches the account operations. For an agent session, jet.key.create is an authority change and waits
for the owner's approval.
A jet key belongs to an app: account.key.create and account.key.publish refuse a key scoped to another
account's jet (JET_KEY_NEEDS_APP, field apps); jet.key.create makes it.
Deprecated
These stay registered, so a client that calls them gets a typed answer, and are never used by the console:
| Operation | Route | What it answers now |
|---|---|---|
featured.enable |
PUT /v1/_accounts/{id}/featured/{jet} |
The jet's entry as featured.list writes it; it writes nothing, since every jet is on |
featured.disable |
DELETE /v1/_accounts/{id}/featured/{jet} |
409 FEATURED_ALWAYS_ON. To stop an app's calls, revoke its jet keys (account.key.revoke) or remove the app |
featured.key.create |
POST /v1/_accounts/{id}/featured/{jet}/keys |
400 JET_KEY_NEEDS_APP: jet.key.create makes the key, for one of the account's apps |
Refusals
| Code | Status | Field | When | What to do |
|---|---|---|---|---|
FEATURED_APP_NOT_FOUND |
404 | jet |
The app is not a featured app (unknown, not featured, or not open to other accounts' keys) | Read featured.list for the names |
TENANT_NOT_FOUND |
404 | slug |
jet.key.create or jet.list names an app that is not the account's |
Read account.app.list for the account's apps |
JET_KEY_NEEDS_APP |
400 | apps (jet on featured.key.create) |
A key scoped to another account's jet with no app of the account to belong to | jet.key.create with the app's slug |
APP_REQUIRED |
400 | jet.start on an account with several apps, naming none. apps holds their slugs |
Call it again with app, or with new_app to make one |
|
JET_KEY_OTHER_APP |
403 | /v1/_jets/{jet}/... on an app's host with a key of another app |
Send a key of the app on this host, or call that app's host | |
FEATURED_ALWAYS_ON |
409 | jet |
featured.disable: a jet cannot be turned off for an account |
Revoke the app's jet keys, or remove the app |
CALLER_USERS_REFUSED |
403 | substrio.caller.users from an app that is not a featured jet |
Only a jet's entry reads the people of the app that called it (app-calls.md) | |
NO_CALLER_APP |
400 | substrio.caller.users in a jet's entry that no app called |
Call the entry from an app's function through substrio.apps |
|
JET_REFUSED |
403 | substrio.jet.use with an id that is not the calling app's, with no caller for an app where the jet keeps no data, or with no call in progress from an app that is not featured |
Pass request.auth.app.id |
|
JET_APP_NOT_FOUND |
404 | substrio.jet.use with an id no app has, or an app whose database could not be opened |
Pass request.auth.app.id |
|
JET_BLOCK_NESTED |
409 | A block opened inside another block | Close the first block, then open the next one | |
JET_BLOCK_CLOSED |
409 | A block's d used after the block ended |
Use d only inside its block |
|
JET_BLOCK_ASYNC |
400 | A block returned a promise | Make network calls outside the block | |
JET_RESULT_NOT_PLAIN |
400 | A block returned a record or another value that is not plain data | Return plain fields, or a chain's json() |
|
JET_TREE_RESERVED |
403 | collection |
An app's code named a collection or store that starts with _jet., or index.create named one |
Reach that data through the jet's routes or an archive |
JET_NOT_DECLARED |
400 | d.collection or d.kv named something featured.hosted does not declare |
Declare the collection or store, then deploy | |
JET_SCHEMA_REFUSED |
409 | An app could not take the schema the jet declares. The app's log holds the step's answer | Change the declaration or the data. The next block tries again | |
JET_ID_CHANGED |
409 | plans.set changed the id in featured.admin |
Keep the id, because each app's data is stored under it | |
JET_ID_TAKEN |
409 | plans.set declared an id another account holds, or an app declaring another jet's id was featured |
Choose another id | |
INVALID_ARGUMENT |
400 | the message's path | plans.set with a menu that is not one: the message names the first bad member, for example featured.app_nav[0].page.kind must be list, form, detail or module |
Fix that member; the menu in force stays until a good one is set |
Past its plan, a jet refuses the call itself with its own typed answer, for example MapJet's 429 USAGE_LIMIT
with counter, limit, used, period and resets_at. When substrio.usage.consume answers reason
"credit", the account's credit has run out and the jet answers 402 CREDIT_EXHAUSTED: the account adds funds
(billing.cart.create with funds).
Making an app a featured app
-
The jet is an app of its own account, open to other accounts' keys (
account.app.open.set). -
Its plans file (
plans.set, orplans_fileinsubstrio.toml) has the counters it meters, its plans and afeaturedblock, with its menu in each app's dashboard (app_nav, below):{"counters": ["requests", "tiles"], "default_plan": "free", "featured": {"title": "MapJet", "summary": "Maps, address search and IP location.", "admin": "/featured/mapjet", "app_nav": [{"id": "maps", "label": "Maps", "icon": "map", "hue": "teal", "page": {"kind": "list", "route": "GET /maps", "items": "maps", "key": "id", "columns": [{"field": "name", "label": "Name"}]}}]}, "plans": [{"id": "free", "limits": {"requests": {"day": 1000, "month": 10000}}}, {"id": "standard", "limits": {"requests": {"month": 1000000}}}]}titleis 1 to 256 characters,summary1 to 512,adminis/featured/and an id (letters, digits,_,-); the console opens it under the account in view. That id is permanent: each app's data for the jet is stored under it (Where a jet keeps an app's data). -
Every call charges the caller's account and names the caller's app:
module.exports = async function (request) { const { account, app } = request.auth; // app: {"id", "slug"}, or null for an account-wide key const u = substrio.usage.consume(account.id, 'requests', 1); if (!u.ok) return new Response(JSON.stringify({ code: 'USAGE_LIMIT', ...u }), { status: 429 }); const maps = db.kv('maps').get(String(app.id)); // each app's own data, under its id return new Response(maps ?? '[]'); };consumeanswers{"ok", "used", "limit", "period", "resets_at"},usedbeing the account's (every app summed). Past a limit it answersok: falsewithreason"limit", and counts nothing. A plan withoverageon the counter goes past the limit instead, paid from the account's credit: the answer addsdrawn, the credit it took in millicents (1/1000 of the minor unit), and when the credit does not hold the units past the limit it answersok: falsewithreason"credit"and counts nothing.reason"no_space": the platform usage file could not grow (the disk), never a call counted as free.substrio.usage.get(account.id, 'requests')reads the account's use,substrio.usage.get(account.id, 'requests', app.id)one app's own ({"day", "month"}).
Its dashboard
A featured app declares its console screens as every app does (dashboard.md): dashboard_file
in substrio.toml, carried in its version. Two things are a featured app's alone:
- Every account reads its dashboard (
dashboard.get), since every account has every jet, so the console shows the jet's pages to its customers, each panel calling the jet's routes as that person. Another account's app that is not featured is answered404 TENANT_NOT_FOUND. - A
modulepanel,{"kind": "module", "module": "<id>"}, draws a console module the jet ships (its own console code, for what panels cannot express). A module panel in any other app's dashboard is refused atversion.checkand at push withDASHBOARD_INVALID,fieldnaming the panel'smodule. Feature the app first, then push the dashboard that uses one.
Where a jet keeps an app's data
Everything an app's owner or its users create through a jet belongs to that app, so a jet stores it in that app's database. It counts toward the app's storage. It is part of the app's backups, archives, and sandboxes, and it is deleted with the app.
There are two kinds of data. App data belongs to the app's owner and travels with the app. Platform data belongs to the jet and stays with it. To tell them apart, ask whether a record should go with the owner if they took the app elsewhere.
A record is app data unless one of these reasons makes it platform data:
| Reason | The test | Examples |
|---|---|---|
| Customers | Is this person a customer of the jet itself? | The jet's own users, sign-ins, and sales leads |
| Money | Does it decide what the jet charges or has charged? | Plans, meters, credits, invoices |
| Running | Does it describe the jet, with no app in it? | Health, the jet's secrets, its provider accounts |
| Shared | Would another app read the same bytes? | Code, map tiles, library templates |
| Protection | Would an app gain by editing, deleting, or rewinding it? | Abuse counters, send caps, issued keys |
| Routing | Is it needed before the app is known? | A domain's app, stored as the key and the app id alone |
A row kept for a reason above holds none of the app's content. A record that fits both sides is split at the field.
A jet reads and writes an app's data in a block. The block receives d, and d.collection(name) and d.kv(name)
work as db.collection(name) and db.kv(name) do:
const body = await request.json();
const open = substrio.jet.use(request.auth.app.id, (d) => {
d.collection('tickets').insert({ subject: body.subject });
d.kv('seen').put(body.subject, 'yes');
return d.collection('tickets').eq('state', 'open').json();
});
return new Response(open);
A block follows these rules:
- A block opens the app that called. Pass
request.auth.app.id. Any other id is refused withJET_REFUSED, so a wrong id in a route never reaches another customer's data. - A chain stays with the app it started from. When app A calls jet B and B calls jet C, C keeps its data in A. Inside an app call the platform names the host, so the id a route passes does not change it.
- Any app can keep data in the app that calls it. A featured jet can also run with no caller. An app that is not
featured opens only the host of the call in progress, which the host's
[apps.use]or key named. It needs afeaturedblock in its plans file for its id and itshostedlist, and no one has to feature it. - An id belongs to one account. The first account to declare an id in
plans.setholds it for good, so no other account can open a jet's data in a host. Another account'splans.setis refused withJET_ID_TAKEN. - A run with no caller opens only an app that uses the jet. A schedule or a delivery event passes an id from one of the jet's own routing rows, and the app must already hold this jet's data. The first block for an app therefore comes from a call that app made.
- A sandbox never reaches a live app. A block in a jet's sandbox opens the sandbox of the app that called it. With any other id, or none, it opens the jet's sandbox itself, so a route can be tried there with test data. A live app's id is not refused here, because the block stays in the sandbox and that app's data is never touched.
dopens only what the jet declares. Any other name is refused withJET_NOT_DECLARED.- Only
dis used inside a block. The jet's owndband other platform calls stay outside it, so read what the block needs first. A collection object fromdstays inside its block too. - A block is synchronous. Make network calls before or after it.
- A block returns plain data or a chain's
json(). A record stays inside its block. - Blocks do not nest. Close one block, then open the next.
- Relations name the full collection. Inside a block, a relation's target is written
_jet.<id>.<name>.
Each jet's data is stored under the jet's id, the word after /featured/ in its admin path. Two jets can use the
same collection name in one app, because their names never meet. The id is permanent, so a rename of the jet leaves
every app's data in place.
An app's own code cannot name these collections, and they do not appear in collection.list. An owner reads them
through the jet's routes and through an archive.
Each cost is the app's own. A block costs one tenant record load, one lock-free lease of the app's database, and one
binding of that database. Each operation inside the block costs what the same db call costs.
Declaring what a jet keeps in an app
A jet declares each collection and store it keeps in the apps that use it, so an owner can see what the jet holds.
Add "home": "hosted" to the collection's file. A store's
file holds {"home": "hosted", "store": true} and nothing else.
{
"home": "hosted",
"schema": { "strict": false, "rules": [{ "field": "subject", "type": "string", "required": true }] },
"indexes": ["by"]
}
substrio push and substrio deploy carry these files in the plans file as featured.hosted. A project that calls
plans.set directly writes the same block:
"hosted": {
"collections": [{
"name": "tickets",
"steps": [
{ "operation": "schema.update", "body": { "strict": false, "rules": [] } },
{ "operation": "index.create", "body": { "kind": "collection", "collection": "tickets", "field": "by", "if_not_exists": true } }
]
}],
"stores": ["seen"]
}
A step is schema.update or index.create, with the body that operation takes. An index.create step names its own
collection, so a declaration reaches only the jet's trees. Anything else is refused with INVALID_ARGUMENT, and
nothing changes.
There is nothing to set up per app. An app takes the declared schema on its first block, and again on its first block
after the declaration changes. Every other block compares one word and pays nothing more. The steps add to what the
app holds and leave its data in place. If an app cannot take a step, for example a new unique index on data that
repeats a value, the block is refused with JET_SCHEMA_REFUSED. The next block tries again.
A hosted collection takes no rules, because only the jet's code reaches it. The app's own index.create and
schema.update cannot change it either. jet.list shows each hosted collection with its document count, and each
hosted store, under hosted. The bytes count toward the app in tenant.usage.self.
When an app moves to a new id
A restore or an import can give an app a new id. The rows in the app travel with it. The rows a jet holds about that
app stay with the jet and still name the old id. A route from a domain to an app is one example. A jet writes those
rows again by naming one of its queues in reattach:
"hosted": { "stores": ["seen"], "reattach": "moved" }
substrio push and substrio deploy keep reattach beside what the collection files add. The queue and its consumer
are declared in the usual way. See Queues.
Each jet that declares reattach and holds data in the app receives one message before the restore or import answers.
The body is {"app": <id>}. The consumer opens the app with that id and writes its rows from what the app holds:
export default {
async queue(batch) {
for (const msg of batch.messages) {
const domains = substrio.jet.use(msg.body.app, (d) => d.collection('domains').find({}));
writeRoutes(msg.body.app, domains);
}
batch.ackAll();
},
};
The queue delivers the message again when a run fails, and a repeated restore sends another message. Write the same rows each time, so that a second run changes nothing.
An owner can edit an archive before restoring it, so the rows in the app are input. Keep the proof that matters in the jet, and check it before a row reaches your routing. A domain's ownership is one example.
A restore into the app's existing id sends the message too. A sandbox receives none. When the jet's live version does
not declare the queue, the data is still restored, and warnings in the answer holds
{"code":"JET_REATTACH_NOT_SENT","jet":"<id>"}.
Only a featured app receives this message. An app that is not featured opens a host only during a call from that
host, so a reattach it declares is never sent.
The app menu
featured.app_nav declares the items the jet adds to each app's dashboard. The console renders each item and
calls the jet's routes as the app using its key at the jet. request.auth.app identifies that app. The declaration
is validated in full by plans.set, and jet.list returns the stored text without rebuilding it for each call.
An item is {"id", "label", "icon"?, "hue"?, "page"}, up to 16 a jet. icon is a word (users, mail, send,
map, files, key, settings, chart, globe, bell, shield, fingerprint; any other word draws a list
mark), hue a console hue (blue, sky, teal, lime, orange, pink, violet).
A route is "METHOD /path": letters, digits, - _ . /, and {field} for a value taken from the row (a list's
update, detail and actions) or from the form.
page.kind |
Members | What the app's owner sees |
|---|---|---|
list |
route (a GET), items (the answer's member holding the rows; absent: the answer is the array), key (the row's id field, default id), columns (1 to 16 {"field", "label", "format"?}), empty?, create?, update?, detail?, actions? (up to 8 {"id", "label", "route", "confirm"?, "danger"?}) |
A table of the rows, paged by the answer's next_cursor when it has one; a button opening create; a row opening update (or detail); each action a button with its confirmation |
form |
route (a POST, PUT or PATCH), fields (1 to 32), load? (a GET filling the fields first), submit? |
A form sending the fields to the route |
detail |
route (a GET), fields |
The fields of the answer |
module |
module (a console module's name) |
A page the console ships for a featured app, for what a table and a form cannot show, such as ReplyJet's inbox, agent, and automations |
create, update and detail are {"route", "label"?, "fields", "load"?}. A field is {"name", "label", "type"?, "required"?, "options"?, "placeholder"?, "help"?}; type is text (default), email, url, number,
boolean, select (with options, 1 to 64), textarea, json, secret, date, code, count or bytes. A
column's format is text, number, date, bool, code, count or bytes.
A worked example, AuthJet's Users page in every app:
{"id": "users", "label": "Users", "icon": "users", "hue": "pink",
"page": {"kind": "list", "route": "GET /v1/users", "items": "users", "key": "id",
"columns": [{"field": "email", "label": "Email"}, {"field": "created_at", "label": "Joined", "format": "date"}],
"create": {"route": "POST /v1/users", "label": "Add a user",
"fields": [{"name": "email", "label": "Email", "type": "email", "required": true}]},
"actions": [{"id": "remove", "label": "Remove", "route": "DELETE /v1/users/{id}",
"confirm": "The user can no longer sign in to this app.", "danger": true}]}}
Calling a jet on your app's host
Every jet's routes are available on the host of each app that uses it, at /v1/_jets/{jet}/{route}. You no longer
have to send your calls to a host that is not yours. A route that MapJet serves at /lookup/8.8.8.8 is served for
the app shop at https://shop.substrio.com/v1/_jets/mapjet/lookup/8.8.8.8:
curl -s https://shop.substrio.com/v1/_jets/mapjet/lookup/8.8.8.8 \
-H "Authorization: Bearer $MAPJET_KEY"
The call needs a key of that app for that jet, which jet.start or jet.key.create gives you. A publishable key
works too, in the header or as ?access_token=, on the routes the jet opens to publishable keys. Everything past the
key is the jet's own: its route, its gate, its rate limits, and the plan of your account at the jet. request.url
holds the route as the jet declares it (/lookup/8.8.8.8).
The jet's own host keeps working with the same key. A site file of the jet is not served at this path.
| Refusal | Status | When |
|---|---|---|
CREDENTIAL_REQUIRED |
401 | There is no key, or the key is not for this jet. This also applies to a route that is public on the jet's host, because a call on an app's host must name its app with a key |
KEY_ORIGIN_REFUSED |
403 | A publishable key was sent from an origin it does not list |
JET_KEY_OTHER_APP |
403 | The key belongs to another app, so call that app's host or send this app's key |
FEATURED_APP_NOT_FOUND |
404 | The name is not a featured app |
ENDPOINT_NOT_FOUND |
404 | No route was named, or the jet does not serve that route, or no app serves this host |
Calling a model from a jet
A featured app can call a language model with substrio.ai.chat. You choose a tier, and the platform picks the
provider, adds its key, and moves to the next provider if one fails. A provider that just failed is tried last for a
short time, so one bad provider does not slow every call. No key is ever visible to your code.
const reply = await substrio.ai.chat({
tier: 'fast',
messages: [{ role: 'user', content: 'Where is my order?' }],
onText: (text) => stream.write(text),
});
// reply.text, reply.tool_calls, reply.usage
| Option | Meaning |
|---|---|
tier |
fast (the default) or smart |
messages |
The conversation, in the OpenAI chat format |
tools |
Tool definitions, in the OpenAI format. Calls come back in reply.tool_calls, each with id, name, and arguments as JSON text |
temperature, max_tokens |
Passed to the model |
onText(text) |
Called with each piece of the answer as it is written |
onRestart() |
Called when a provider stopped partway and the next one starts the answer again |
reply.usage holds provider, model, input_tokens, output_tokens, and cost_millicents. estimated is true
when a provider gave no token count, so the figures were estimated from the text. Your jet meters the call itself, for
example with substrio.usage.consume. If a provider stopped partway, reply.partial holds what it had written.
| Refusal | When |
|---|---|
AI_NOT_AVAILABLE |
The app is not a featured app |
AI_TIER_EMPTY |
The tier has no usable provider |
AI_PROVIDERS_FAILED |
Every provider of the tier failed. Try again shortly |
INVALID_ARGUMENT |
tier is not fast or smart |
Calling an app's tools from a jet
A featured app can reach the tools an outside agent of an app gets. That covers the app's own routes, the tools of its connected apps, and the tools Substrio provides.
const { app, platform } = substrio.jet.tools(request.auth.app.id);
const out = substrio.jet.call(request.auth.app.id, 'orders_refund', JSON.stringify({ id, amount }), visitorAuthorization);
// out.status, out.body
substrio.jet.tools(appId) answers { app, platform }. Each entry of app is a tools/list entry with its route,
area, lead, and approval. Each entry of platform is a platform tool with its annotations.
substrio.jet.call(appId, tool, argumentsJson, authorization?) runs one tool and answers { status, body }.
- An app tool runs the function behind its route, under that route's own gate. Pass a person's
Authorizationvalue and the gate judges that person, so the call reaches what they could reach themselves. An empty string is a visitor who is signed in to nothing, so only public routes answer. When you leave the argument out, the call is judged as the key of the account that owns the app. - A connected app's tool runs at that app, judged as that account's key. It needs the argument left out.
- A platform tool runs as the app, as
substrio.apidoes. It needs the argument left out.
The calling app's own sandbox is the one other id these calls take, so a jet can run a trial on a copy
of the app's data. Any other app's id is refused. A sandbox takes its tools from the version it runs, so a tool that
was set by itself with mcp.tool.set and is in no version is not there.
A call runs on the thread already serving your function, so it makes no HTTP request. Every call counts toward the
tool's calls in mcp.tool.list. A tool's refusal is an ordinary answer with its status and error body. These calls
cannot run inside a db.transaction.
| Refusal | When |
|---|---|
JET_REFUSED |
The id is not the calling app's, as with substrio.jet.use |
INVALID_ARGUMENT |
tool or argumentsJson is not a string |
Watching an app's writes from a jet
substrio.jet.watch(appId, collection, type, functionName) runs one of your functions on each matching write to that
app. That covers a write through the data API, an SDK, or MCP, and a write that the app's own function makes with db.
A SQL statement starts no watch, and neither does a write that your own watch function makes. Records your jet hosts
start no watch either. A write in a transaction starts its watch when the transaction commits. The write answers
without waiting for your function. type is insert, update, or delete, and collection may be *. The answer is
the watch's id, and asking again for the same watch answers the same id.
const id = substrio.jet.watch(request.auth.app.id, 'orders', 'insert', 'on_order');
// functions/on_order.js
module.exports = (event) => {
const { app, collection, documentId, data } = event.trigger;
substrio.jet.use(app, (d) => d.collection('seen').insert({ collection, documentId }));
};
The function runs as a trigger runs (cloud-functions.md), with retries and in write order per
record. Writes made during the run fire no further triggers. A version the app pushes leaves your watches in place, and
the app's owner sees each one in trigger.list with a jet member. substrio.jet.unwatch(appId, id) ends a watch and
answers whether your jet held it.
Calling a jet from an app's function
A jet may offer entries to every app of every account with the word @featured in its offer
(app-calls.md): PUT /v1/_app_calls {"offer": {"where": ["@featured"]}}. An app that declares its
use ({"use": {"mapjet": ["where"]}}) calls substrio.apps.mapjet.where() as itself, with no key: the jet's
entry reads the calling app with substrio.auth.getCurrentUser() ({"user_id": null, "account": {"id"}, "app": {"id", "slug", "account"}}) and meters it with substrio.usage.consume exactly as a keyed call, the app's own use
counted beside.
A worked example: an app using MapJet and SendJet
The jet's side is one word in its substrio.toml, applied by substrio deploy (MapJet offers search, geocode,
reverse, lookup and usage; SendJet offers send, email, templates and template; each ["@featured"]):
[apps.offer]
reverse = ["@featured"]
The caller's side is its own word and its own function. The app declares the use ([apps.use] in its
substrio.toml, or PUT /v1/_app_calls {"use": {"mapjet": ["reverse"], "sendjet": ["send"]}}) and calls with no key:
module.exports = async function (request) {
try {
const place = await substrio.apps.mapjet.reverse(36.17, -115.14); // { city: 'LAS VEGAS', state: 'NV', ... }
const sent = await substrio.apps.sendjet.send({ template: 'welcome', to: '[email protected]', variables: {} }); // { id, status }
return Response.json({ place, sent });
} catch (e) {
return Response.json({ code: e.code, message: e.message }, { status: 400 });
}
};
Each call is metered on the calling app's account at the jet's own counter (MapJet's requests, one per call; a
SendJet message counts as it does on POST /emails), with the app's own use counted beside; usage() at MapJet
answers the account's use for free. A refusal is a thrown Error with the jet's own code and message only:
| Code | Jet | When |
|---|---|---|
USAGE_LIMIT |
MapJet | The account's plan at the jet is spent (nothing counted); the message names the counter, limit, period and resets_at |
COORDINATES_INVALID, NO_PLACE, PRIVATE_IP, INVALID_IP, IP_NOT_FOUND, GEO_BAD_REQUEST |
MapJet | The route's own refusals for the same input |
required_field, invalid_request, bad_address, unknown_template, not_found |
SendJet | POST /emails's and GET /emails/:id's own refusals |
forbidden |
SendJet | The caller's account has not set SendJet up (a workspace is made in the SendJet dashboard first) |
app_call_not_granted |
any | The app declared other uses but not this entry (an app with no [apps.use] has no substrio.apps at all) |
A jet attaches to another jet the same way: a jet is an ordinary app, so one that declares [apps.use] of
another jet's entries calls them as itself, and the call is metered on the calling jet's own account.
An agent's flow
featured.list {id} -> apps[]: mapjet, plan free, usage
jet.key.create {id, slug: "shop", jet: "mapjet", name}
-> key (shown once), app "shop"
GET https://mapjet.net/... with the key -> request.auth.app is shop; metered to the account's plan
jet.list {id, slug: "shop"} -> usage (every app), app_usage (shop), app_nav
account.app.delete {id, slug: "shop"} -> the shop's jet keys stop at their next request
Proven end to end, over MCP on a real server, by tests/integration/featured_apps.test.mjs: two apps of one
account summing under one limit, each refused at the shared limit, each seeing only its own data, a function
calling a jet as its app, no enable step anywhere.