NodeApp API
Write small apps that Jaw nodes host: polls, games, forms, tools for a community. A NodeApp is a JavaScript script that runs only on the node, in a sandbox. It never runs on anyone's device: it sends screens built from a fixed set of elements, and the Jaw app draws them with its own controls.
How NodeApps work
- A node's owner installs your signed
.JawApppackage. The node runs your script in a sandbox: plain JavaScript, no files, no timers, no modules, network only to the hosts its manifest lists, and limits on time and memory for every action. - Someone opens your app: the node calls
onOpenand sends back the screen it returns. They press a button: the node callsonActionwith the button and the values of the inputs next to it, and sends the next screen. Typing stays on their device until they press a button. - Screens are data, not code: a tree of predefined elements (text, tables, buttons, text boxes…). Jaw decides how everything looks, inside a border labelled with your app's name, so an app can't imitate Jaw or run anything on a person's device.
- Listed apps are reached through forwarders: nobody on the way learns both who asked and which app, and your app never learns who anyone is or which node they use. It sees a separate stranger per person (or one session for a room).
Get jaw-app
jaw-app is the NodeApp SDK: it makes an app from a template, runs it on your machine in the same
sandbox nodes use, with a preview drawn by the Jaw app's own screen component, checks it, and signs it. It comes
in the Jaw API package, next to the bot SDK.
Jaw API 0.0.23
33.8 MB · SHA-256 d63e721265da9a9bf6442a407c125d982111a80492d30fb9cfd42e8fb29e2fbe
Download the Jaw APIIt comes from this node, like everything on the downloads page: make sure you trust the node, or compare the SHA-256 with another node's.
You need the .NET 10 SDK (dot.net). Unzip the package, then from its folder:
dotnet tool install --global Jaw.AppKit --add-source ./packages --version 0.0.23
From the Jaw repository instead: dotnet run --project tools/Jaw.AppKit -- <command>.
| Command | What it does |
|---|---|
jaw-app new <template> [folder] | Starts an app: counter (one count per person), poll (one vote each in a room), tictactoe (two players take turns), lightsout (a single-player puzzle with a shared best score), weather (fetches a forecast from the web: see Reaching the web). |
jaw-app run [folder] [--port N] | Runs it at http://127.0.0.1:5199. Pick who you are (Alice, Bob or Carol) and the mode; saving a file reloads it. Nothing leaves your machine. |
jaw-app check [folder] | Reads the folder as a node would and opens every mode's first screen. |
jaw-app pack [folder] [--key F] | Signs it into Name-version.JawApp with your author key. |
jaw-app key [--key F] | Shows (or makes) your author key: ~/.jaw-app/author.key. Back it up; sign later versions with it. |
jaw-app editor [folder] | Adds editor help (new apps get it): types for the script API and the manifest's schema, so editors such as VS Code complete and check your files. Never packed. |
Quick start
jaw-app new counter my-app
jaw-app run my-app
That makes a folder with two files. manifest.json:
{ "name": "Counter", "description": "Counts your presses, and everyone's", "version": "1.0.0",
"main": "main.js", "modes": ["individual"], "storageBytes": 1024 }
main.js:
function onOpen(session) { return view(session); }
function onAction(session, action) {
if (action.button === "add") {
session.state.count = (session.state.count || 0) + action.values.step;
storage.set("total", String(Number(storage.get("total") || 0) + action.values.step));
}
return view(session);
}
function view(session) {
return { title: "Counter", body: [
{ type: "text", text: "You: " + (session.state.count || 0) + " · everyone: " + (storage.get("total") || 0) },
{ type: "group", title: "Add", children: [
{ type: "number", id: "step", value: 1, min: 1, max: 10 },
{ type: "button", id: "add", text: "Add", style: "primary" } ] } ] };
}
Change the text, save, and the preview reloads. When it works: jaw-app check my-app, then
jaw-app pack my-app, and give the .JawApp to a node's owner (Publishing).
The manifest
| Field | Meaning | Default, limits |
|---|---|---|
name | Shown to people | required, 1-48 characters |
description | One line | up to 300 characters |
version | Put on the package by jaw-app pack (nodes ignore it) | 1.0.0 |
main | The script | main.js, at most 512 KB |
modes | individual, room, room-pseudonyms (Sessions) | ["individual"] |
storageBytes | Room for the app's own store | 0, up to 16 MB |
runMilliseconds | Time per action | 200, up to 2,000 |
memoryMegabytes | Memory allocated per action (everything allocated during the run counts) | 16, up to 128 |
network | Hosts the app reaches (https), or ["any"] alone | none; up to 16 hosts |
The node's owner sees what the manifest asks for and approves it when installing. People using the app never see it (an app could lie in it); they see the name, description, AppID and who signed it.
The script
Define two functions; both return a screen:
onOpen(session): optional, for the first screen;onAction(session, action): required.actionis{ button, values: { id: value } }(ornullwhen opened, if there's noonOpen).
session is { id, mode, state, member }:
| Field | What it is |
|---|---|
id | A stable name for this session. The app never learns who anyone is. |
mode | individual, room or room-pseudonyms. |
state | An object you may change; kept for the session's next action (at most 16 KB as JSON). |
member | In room-pseudonyms mode, a stable name for the person pressing (different in every session and app); otherwise null. |
Each action gets a fresh sandbox: keep nothing in global variables. A script that runs too long, uses too much memory, recurses too deep, throws, or returns something that isn't a valid screen fails that action only: the person sees why, and nothing it changed is kept.
Reaching the web
An app can fetch data, a weather forecast for example, from the hosts its manifest lists under network,
or from any website with ["any"]. The node makes the requests, never anyone's device, so
the website sees the node, not the person. Make onOpen/onAction async and await
http:
async function onOpen(session) {
const data = await http.getJson("https://api.open-meteo.com/v1/forecast?latitude=59.9&longitude=10.7¤t=temperature_2m");
return { title: "Oslo", body: [ { type: "text", text: data.current.temperature_2m + " °C" } ] };
}
| Call | What it does |
|---|---|
http.get(url, headers?) | Resolves to { status, body, contentType }. |
http.post(url, body, headers?) | The same, with a text body (set content-type in the headers). |
http.getJson(url, headers?) | GETs and parses JSON; rejects unless the status is 2xx. |
Fixed rules, whatever the manifest says:
- https on the usual port only; GET and POST; redirects aren't followed; no cookies.
- Only public addresses: never the node's own machine or local network, checked on the address actually reached.
- Headers:
accept,accept-language,authorization,content-typeandx-…. - At most 4 requests per action and 60 a minute per app; 64 KB sent and
256 KB read per request; 5 seconds each (15 through Tor). An action may wait that long for each
request on top of its
runMilliseconds. - On a node that is reachable only as an onion service, every request goes through Tor, so websites never learn the node's address. If Tor isn't working, the request fails; it's never sent directly.
The owner sees what the app reaches when installing it (installing is the consent), and can turn one app's network off later in Settings → Apps on this node. A refused or failed request rejects with the reason; catch it to show something useful.
Sessions and modes
| Mode | Who shares state | member | Good for |
|---|---|---|---|
individual | nobody: one session per person | null | private tools, forms |
room | everyone who can read the app channel (or the message that embeds the app) | null: you can't tell people apart | shared boards, counters |
room-pseudonyms | the same: one shared session | a different stable name per person | votes, turns, games |
Room sessions follow the room. When someone is removed, the room's session moves to a new key they don't have, and
your app keeps its state; you don't see this happen. An app channel and each message that embeds your app are separate
sessions. In jaw-app run, Alice, Bob and Carol are three people in the same room.
Screens
Return { title, body }, where body is a list of elements shown top to bottom:
{ title: "Weather",
body: [
{ type: "heading", text: "London" },
{ type: "text", text: [ "Now ", { text: "14°C", style: "bold" }, " and dry." ] },
{ type: "group", title: "Another city", children: [
{ type: "textbox", id: "city", label: "City", maxLength: 40 },
{ type: "button", id: "show", text: "Show", style: "primary" } ] } ] }
Elements
type | Fields | Notes |
|---|---|---|
column, row | children | Stack elements down, or side by side (wrapping). |
group | title, children | A form: a button inside sends only the inputs inside. |
divider | A line. | |
heading | text, level (1-3) | |
text | text: a string, or a list of strings and { text, style } | Styles: plain, bold, italic, code. Line breaks are kept. |
list | items (strings) | Up to 200 items. |
table | rows (lists of strings), header | Up to 1,000 cells. |
progress | percent (0-100), label | |
image | data (base64 PNG or JPEG), alt | At most 4 per screen, 64 KB and 1,024 px a side. Never a URL: fetching one would reveal the person's IP address. |
link | text, url (http or https) | Jaw shows the address and warns before opening it outside Jaw. |
textbox | id, label, value, placeholder, multiline, password, maxLength | password hides what's typed. |
number | id, label, value, min, max | Whole numbers. |
checkbox, toggle | id, label, checked | |
choice | id, label, options (strings, or { value, label }), selected, dropdown | Radio buttons, or a dropdown. Up to 100 options. |
button | id, text, style (normal, primary, danger) |
Ids (inputs and buttons): 1-64 letters, digits, -, _ or ., unique within the screen.
There is no styling beyond this: Jaw decides how everything looks.
Actions
When someone presses a button, the action holds its id and, for every input in the same group
(or, for a button outside any group, every input outside a group): text boxes and choices as text, checkboxes and toggles
as true or false, numbers as whole numbers. Inputs nobody touched send the value the screen gave them.
The node checks every action against the screen it answers before your script sees it, so an action can't name a button or input the screen didn't have, or a value it doesn't allow (too long, out of range, not an option).
Storage
storage.get(key) and storage.set(key, value) (strings; null removes) reach the
app's own store on the node, shared by every session, within storageBytes. set returns
false when the store is full. Use session.state for what belongs to one session.
Limits
| What | Limit |
|---|---|
| A screen, encoded | 256 KB, 500 elements, 8 levels deep |
| Texts and titles | 4,000 and 100 characters |
| Session state | 16 KB as JSON |
| Work per action | 5,000,000 statements, recursion 128 deep, plus runMilliseconds and memoryMegabytes |
| Requests | 60 a minute per session |
| Sessions kept | 1,000 per app (the least recently used go first) |
Publishing: install, list, AppIDs
jaw-app pack my-appwritesmy-app/Name-version.JawApp, signed by your author key. Owners see it as "by account …" with your key's fingerprint; sign every later version with the same key.- A node's owner installs it in the Jaw app: Settings → Apps on this node. The node's own people can open it from there.
- To offer it to everyone, the owner presses List in Apps and gives it an AppID, a
network-wide name like
nodeapp://counter. AppIDs are claimed like handles: a little proof-of-work, backed by witness nodes, fixed after 6 hours, renewed monthly by the node. App names never use up handles. - The app then appears in everyone's Apps tab. Its advert names no node: it's published through a forwarder, and requests reach the app through three forwarders.
The same app on five nodes is five apps, with five keys and five AppIDs.
In rooms: app channels and embeds
- App channels: someone with the
ManageAppspermission adds a channel of kind App (Room settings → Add channel), naming your AppID and a mode (roomorroom-pseudonyms). Everyone who can read the channel shares the session. - Embeds: a message containing
nodeapp://countershows your app under it, optionallynodeapp://poll?mode=pseudonymsor?mode=room. Each embed is its own session for everyone who reads the message. Channels can turn embeds off (EmbedApps). - Every app is grayed out behind a warning until a person chooses to open it (or to always trust it).
What apps learn (and don't)
People are warned that an app and its node can see and keep everything they send it. In return, your app never learns who they are: in individual mode it sees a separate stranger per person (and per app), in rooms only the room's session and, if you asked for pseudonyms, a name per person that means nothing outside your session. It doesn't see the room's members or messages. Ask for no more than you need.
NodeApps or bots?
| NodeApp | Bot | |
|---|---|---|
| Runs | on a node, in a sandbox | in your own program, anywhere |
| Language | JavaScript | C# (.NET) |
| Talks through | screens Jaw draws | messages, slash commands, buttons |
| Knows who you are | no | yes: it's a member of the room |
| Network, files | https to the hosts it lists, through the node; no files | whatever your program can do |
Troubleshooting
| You see | What to do |
|---|---|
| "The app's screen has changed; open it again." | The press didn't fit the screen that session was last shown (someone else changed a shared session). Reopen it. |
| "This app doesn't offer that kind of session." | Add the mode to modes in the manifest. |
| "The app took too long" / "used too much memory" | Raise runMilliseconds or memoryMegabytes (within the limits), or do less per action. |
| "The app's screen isn't valid: …" | The message names the rule. jaw-app run shows the same message. |
| A new version shows a different author | It was signed with another key: use the backed-up ~/.jaw-app/author.key. |