Featured apps

A featured app, or jet, is a Substrio app available to apps in every account.

Docs menu

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

  1. The jet is an app of its own account, open to other accounts' keys (account.app.open.set).

  2. Its plans file (plans.set, or plans_file in substrio.toml) has the counters it meters, its plans and a featured block, 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}}}]}

    title is 1 to 256 characters, summary 1 to 512, admin is /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).

  3. 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 ?? '[]');
    };

    consume answers {"ok", "used", "limit", "period", "resets_at"}, used being the account's (every app summed). Past a limit it answers ok: false with reason "limit", and counts nothing. A plan with overage on the counter goes past the limit instead, paid from the account's credit: the answer adds drawn, 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 answers ok: false with reason "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 answered 404 TENANT_NOT_FOUND.
  • A module panel, {"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 at version.check and at push with DASHBOARD_INVALID, field naming the panel's module. 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 with JET_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 a featured block in its plans file for its id and its hosted list, and no one has to feature it.
  • An id belongs to one account. The first account to declare an id in plans.set holds it for good, so no other account can open a jet's data in a host. Another account's plans.set is refused with JET_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.
  • d opens only what the jet declares. Any other name is refused with JET_NOT_DECLARED.
  • Only d is used inside a block. The jet's own db and other platform calls stay outside it, so read what the block needs first. A collection object from d stays 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 Authorization value 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.api does. 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.