An app can call a function another app offers (substrio.apps.<app>.<entry>(...args)) or read an
offered query (substrio.apps.<app>.query.<name>(...args)). Both apps must declare the connection. The
providing app offers the function or query, and the calling app declares its use.
The word
In substrio.toml, sent by substrio deploy (operation apps.calls.set, PUT /v1/_app_calls; read back with
apps.calls.get, GET /v1/_app_calls):
# The owner (app "people")
[apps.offer]
peopleByEmail = ["shop", "@account"] # the function, offered to app "shop" and every app of this account
[apps.query.byEmail]
collection = "users"
where = ["email"] # equality on each field, bound to argument i; each declared indexed
select = ["name", "email"] # the only fields a row answers
to = ["shop"]
limit = 100 # optional: at most this many rows (absent: every match)
# The user (app "shop")
[apps.use]
people = ["peopleByEmail"]
[apps.use_query]
people = ["byEmail"] # or { byEmail = ["name"] } to read only some selected fields
"@account" offers to every app in the provider's account. "@featured", on a featured app only (a jet,
featured-apps.md), offers to every app of every account: the 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 on the caller's account, the app's own use counted beside. On an app that
is not featured, it offers to no one. The entire declaration is validated. An invalid declaration is
refused with the path of the invalid member, and the current declaration remains in force. A declaration
is stored before the response is returned.
You can name an app that does not exist yet, so deployment order is flexible. The response includes
"warnings": [{"code": "APP_USE_UNKNOWN_APP", "app": "<slug>"}].
Using it
// Held once at the top of the file: deploys and runs as fast as a call made inside the handler.
const byEmail = substrio.apps.people.query.byEmail;
module.exports = async function (request) {
const rows = byEmail('[email protected]'); // [{ name: 'Ann', email: '[email protected]' }]
const person = substrio.apps.people.peopleByEmail('[email protected]');
return Response.json({ rows, person });
};
- A read runs directly on the provider's store as an index seek, without running the provider's code. Rows are plain objects in the reader's heap and contain only the selected fields.
- A call runs the provider's function in its isolate, under its limits. Arguments and the result must be plain data.
- An app that names no other app has no
substrio.appsat all (typeof substrio.apps === 'undefined'). - A sandbox reaches the other app's sandbox of the same name, never its live data. A test app reaches only test apps.
- A change to either declaration or a deployment of either app applies to the next call or read, including one referenced by a variable.
- Every call and read is counted on both apps (
APP_CALLS).
One MCP for connected apps
The same declaration connects apps' MCP tools. An app named under [apps.use] or [apps.use_query] adds
each tool it offers you to your /mcp, under the name <app>_<tool>. The tool's offer member controls
the offer. An agent signed in to your app calls it as the same person. The call runs in the other app under
its route gate and as that person's user there. See Connected apps for offer, the errors
(MCP_LINK_NOT_LINKED, MCP_LINK_NOT_OFFERED) and the cost.
Keeping data in the calling app
An app stores what another app creates through it in that app's database, with
substrio.jet.use(appId, (d) => ...). Its route or entry passes request.auth.app.id. When one app's entry calls
another's, the data goes to the app the call started from. The data then counts toward
that app's storage, and the app's backups, archives, and sandboxes carry it. See
Where a jet keeps an app's data.
Reading the caller's users
An entry in a featured app (a jet offered to every app with "@featured"; see
featured-apps.md) can read and add users in the calling app's _users collection. It
requires no key or second store:
// AuthJet's entry, offered to every app: the people of whichever app called it.
module.exports = function (options) {
const page = substrio.caller.users.list({ limit: 50, prefix: 'a' }); // { users, total, returned, limit, skip, next_cursor }
const one = substrio.caller.users.get(page.users[0].user_id); // user.get's members, has_password included
const made = substrio.caller.users.invite('[email protected]', { name: 'Ada' }); // { user_id }
return { page, one, made };
};
list({limit, skip, cursor, prefix})runsuser.liston the calling app with the same seek inU<email>order.next_cursoris the last email on a page, ornullon the last page.limitdefaults to 50.get(id)takes theuser_idreturned by a list, as a number or decimal text, likeuser.get.invite(email, {name})isuser.createwith no password: a person with no credential, who signs up later and claims it. The same address, in any case, is the same person; nobody is added twice.- The calling app is set when the call is admitted. It is not an argument, and an
appmember in the options is ignored. Only a featured app's entry can read the users of the app that called it. - Cost per call over
user.list: no HTTP parse, no request build and no admission or session check; one tenant record load (the entry's own app must be a featured jet) and one lock-free lease of the caller's database; the same seek and answer; no callback through the C API. - Within a jet-to-jet call, the caller app is the calling jet, never the end app: when SendJet's entry calls
AuthJet's through
substrio.apps,substrio.caller.usersin AuthJet's entry reads SendJet's people (SendJet has none) and not those of the app that called SendJet. A jet that needs the end app's people reads them in its own entry, where the end app is its caller, and passes them on as plain data. - A jet's route reached with an app's key has that app as its caller app too; the jet's own code is trusted, and a
route that reads the people answers only what the jet's author meant, so gate it (
auth).
Refusals
A refused call or read throws an Error whose name and code are the code below, with a message naming the
app.
| code | when |
|---|---|
app_call_unknown_app / app_query_unknown_app |
no app has that slug (never made, or deleted) |
app_call_unavailable / app_query_unavailable |
the app exists but is suspended, or its database could not be opened |
app_call_not_offered / app_query_not_offered |
the owner offers no such entry or query |
app_call_not_granted / app_query_not_granted |
offered, but not to this app, or this app no longer declares it |
app_query_bad_argument |
not one argument per where field, or an argument that is not a string, number or boolean |
app_query_not_servable |
the query's collection is gone, or a where field is no longer declared indexed |
app_query_field_not_exposed |
the reader names a field the owner's offer does not select |
app_call_value_not_copyable |
an argument or the answer is not plain data (a function, promise, class instance, cycle) |
app_call_not_local |
the owner runs where this call cannot reach it |
cpu_timeout / memory_exceeded |
the owner's own CPU or heap limit ended the call (the caller goes on) |
substrio.caller.users throws an Error whose name and code are the error-table code:
| code | status | when |
|---|---|---|
CALLER_USERS_REFUSED (0xC088) |
403 | the entry's own app is not a featured jet: an app you merely use never reads your users |
NO_CALLER_APP (0xC089) |
400 | the featured jet's entry was called by no app (a route, a schedule, a direct call), or the calling app is gone |
USER_NOT_FOUND |
404 | get of an id the calling app does not hold |
EMAIL_INVALID |
400 | invite of an address that cannot be stored |
INVALID_ARGUMENT |
400 | list with a limit or skip that is not a whole number (a limit of 0), get with an id that is not a person's |
Refusals at apps.calls.set (each answered with its error-table status):
| code | when |
|---|---|
INVALID_ARGUMENT |
the document is malformed (the detail names the path) |
COLLECTION_NOT_FOUND |
an offered query names a collection that does not exist |
APP_QUERY_NOT_INDEXED |
a where field (other than _id) is not declared indexed or unique |
APP_QUERY_FIELD_NOT_EXPOSED |
use_query names a field the owner's granted offer does not select |