Writing a Mini-app (Jenny App)
A Jenny App is a declarative manifest plus, optionally, a static HTML/JS screen, a Python reader and a summary — this page is the technical contract for extending one, as implemented in jenny/apps/. For the user-facing “how do I get an app” story, see Mini-apps.
In normal use, apps are generated by Jenny in chat via the built-in app-creator skill and her app tool, never by hand — and an app she writes exists only once it works (see Drafts). This page is for contributors extending the Jenny Apps runtime itself (jenny/apps/), or for anyone who wants to understand or edit an app’s files directly instead of going through chat.
The runtime, at a glance
Section titled “The runtime, at a glance”| Layer | Module |
|---|---|
| Manifest parsing/validation | jenny/apps/manifest.py |
| Storage action executor (JSONL) | jenny/apps/storage.py |
| HTTP action executor (proxy) | jenny/apps/http.py |
| Shared action dispatch | jenny/apps/executor.py |
| Gateway HTTP routes | jenny/webui/apps_routes.py (dispatch, auth, limits) → jenny/webui/apps_api.py (payload execution) |
| Agent-facing tool + per-turn sync | jenny/agent/tools/app_actions.py |
| Jenny’s writer: draft, edit, place, trash | jenny/agent/tools/app_tool.py |
Drafts (the app tool’s workbench) |
jenny/apps/drafts.py |
Reader (run.py) runtime |
jenny/apps/reader.py (reuses the execution of jenny/board/micro.py) |
| Summary (what an app card shows) | jenny/apps/summary_view.py |
| Placement permission | jenny/apps/permissions.py, jenny/board/placements.py |
| Trash | jenny/apps/trash.py |
| Frontend SDK served to the iframe | jenny/templates/ui/assets/apps/jenny-sdk.js |
The whole subsystem sits behind one switch: with apps.enabled: false in config.json, every apps route (static files, listing, actions) answers 503 apps are disabled.
Hard transport constraint. The gateway’s HTTP layer is built on websockets’ http11 parser, which rejects any non-GET method and any request body at the parser level — before your code ever sees the request. Every action, storage or http, executes as GET /api/apps/<slug>/actions/<name>?params=<url-encoded JSON>&token=.... Don’t try to “fix” this to POST — it’s a property of the server, not an oversight, and it’s also why there’s no CORS preflight to handle: the sandboxed iframe only ever issues simple GETs, and every action response carries Access-Control-Allow-Origin: *.
The direct consequence is a hard cap on how much data an action can take: the URL-encoded params string is limited to 6000 characters (APP_PARAMS_MAX_CHARS in apps_routes.py, sized to leave headroom under the 8192-byte request-line budget websockets enforces). Go over and the call fails with status 413 and params exceed 6000 chars — no truncation, no chunking. An app that needs to store a long note is fine; an app that tries to upload a payload through an action is not.
Folder layout
Section titled “Folder layout”workspace/apps/<slug>/├── app.json # manifest├── AGENT.md # briefing for Jenny — not part of the technical contract├── run.py # optional reader: def run(ctx)├── app/│ └── index.html # optional screen (HTML/JS)└── data/ # JSONL collections (readings.jsonl, latest.json, state.json for a reader)workspace/apps/.drafts/<slug>/ # the app tool's workbench (invisible to scan_apps)workspace/apps/.trash/<slug>-<UTC>/ # deleted apps, data includedOnly app/ is web-served: GET /apps/<slug>/<rel> maps to apps/<slug>/app/<rel>. app.json, AGENT.md, and everything under data/ are never reachable by URL — the only way to touch app data from outside is through a declared action.
Apps live under workspace/apps/, never workspace/ui/ — the latter is bundled UI content, re-extracted from the installed package on every gateway startup (jenny/utils/helpers.py); anything you put there is overwritten on the next launch.
The manifest (app.json)
Section titled “The manifest (app.json)”Loading is designed to never raise: load_app() (jenny/apps/manifest.py) always returns a LoadedApp, with broken=True and a readable error string on any problem, so a malformed manifest shows up in the app drawer as broken instead of crashing the gateway.
| Field | Required | Notes |
|---|---|---|
name |
yes | Non-empty display name. |
description |
yes | Non-empty, one line. |
icon |
no | Tabler icon name; defaults to ti-apps. |
server |
no | {"baseUrl": "http://..."} (or https://) — required if any action has kind: "http", or if view.kind is "external". Do not add an auth block: it is rejected at load time (see below). |
actions |
no | Array of actions (see below). May be omitted or empty: an app whose only job is to draw a screen has nothing agent-facing to declare. Names must be unique. |
view |
no | {"kind": "external"} — the app’s screen is the UI of its own server.baseUrl, served through a loopback proxy (jenny/apps/proxy.py), instead of app/index.html. "external" is the only accepted kind. |
summary |
for stateful apps | How the app shows on its card on a board. See Summary below. section is its older name, still read. |
reader |
no | A run.py the clock runs every N minutes with no model. See Reader below. |
The folder name itself is the slug and must match ^[a-z0-9]+(-[a-z0-9]+)*$, max 32 characters (MAX_SLUG_LEN in manifest.py) — a folder that doesn’t match is loaded as broken with "folder name '<slug>' is not a valid slug".
Actions
Section titled “Actions”Every action needs:
| Field | Required | Notes |
|---|---|---|
name |
yes | Matches ^[a-z][a-z0-9_]*$, unique within the app. Exposed as tool <slug>_<name>. |
description |
yes | This is what the LLM reads to decide when to call the action — write it for the model. |
kind |
yes | "storage" or "http" — nothing else. A python kind is explicitly not supported: server-side logic belongs behind the app’s own external server, called via an http action. |
params |
no | Map of param name → JSON Schema fragment (must include "type"). |
required |
no | Subset of params keys. |
Params not declared in the schema are rejected outright at call time (unknown params: ...) — every field the action accepts must be declared, there’s no passthrough.
storage actions additionally need:
| Field | Notes |
|---|---|
op |
One of append, set, update, delete, query. |
collection |
Lowercase alphanumeric words separated by single hyphens (same shape as a slug); maps to data/<collection>.jsonl. |
Reserved params get auto-injected into the effective schema and must not be redeclared: id (required, on set/update/delete) and limit (on query; default 200 — a page size, not a filter).
http actions additionally need:
| Field | Notes |
|---|---|
method |
GET, POST, PUT, PATCH, or DELETE. |
path |
Must start with /; {placeholder} segments must match declared params. |
Params not consumed by a path placeholder go into the query string (GET/DELETE) or the JSON body (other methods).
Full example (a folder named piante):
{ "name": "Piante", "description": "Plant monitoring: humidity, status, care log", "server": { "baseUrl": "http://192.168.1.50:8080" }, "actions": [ { "name": "lista_piante", "kind": "http", "method": "GET", "path": "/plants", "description": "List plants with status" }, { "name": "umidita_pianta", "kind": "http", "method": "GET", "path": "/plants/{id}/humidity", "params": { "id": { "type": "string" } }, "required": ["id"], "description": "Current humidity for one plant" }, { "name": "annota_cura", "kind": "storage", "op": "append", "collection": "cure", "params": { "pianta": { "type": "string" }, "nota": { "type": "string" } }, "required": ["pianta"], "description": "Log a care event" } ]}Summary (summary) — the strip
Section titled “Summary (summary) — the strip”How the app shows outside its screen: on a notebook’s board it is a strip — a header and a few blocks that the house draws (assets/home-board-blocks.js), the same on the board and in a drawer. It is required for an app that keeps state (a reader, or a mutating storage action): the app tool refuses such an app without one.
"summary": { "collection": "items", "meta": { "count": true, "where": { "done": false }, "label": "to buy" }, "blocks": [ { "kind": "checklist", "text": "what", "done": "done", "toggle": "set_done", "max": 4 }, { "kind": "buttons", "actions": ["clear_done"] } ]}jenny/apps/slip_spec.py::parse_slipvalidates the blocks — a closed vocabulary of 16 (value,numbers,chart,status,progress,bars,table,events,checklist,buttons,switches,counter,week,month,note,photos), 1 to 4 per strip — and picks the size from whole rows: S 2 · M 4 · L 6 · XL 9 (jenny/board/model.py::SLIP_ROWS). Lists count at theirmax(≤ 5), so the size comes from the manifest, never from the data; over XL the app is broken with a readable error. The full catalog with fields is in the skill’sreferences/manifest.md.- Controls (
checklisttoggle,buttons,switches,counter) may name only actions the app declares, and only ones a tap can fill: an action with otherrequiredparams is refused at load. jenny/apps/slip_view.py::slip_viewcomputes what each block draws, from the readings or the named collections filtered by the placement’sparams/where. The strip’s state (ok, stale, alarm, empty, broken) issummary_view’s, the same one the chat and the clock read.photosshows the newest images in the app’sdata/media/. That folder is the only part ofdata/the house can read, throughGET /api/webui/apps/<slug>/media/<name>(apps_routes._media): the WebUI secret, image extensions only (jpg, png, webp, gif), direct children only, at most 8 MB; the client turns each into ablob:.- Without
blocksthe older forms still work, translated into blocks byslip_spec.legacy_blockswithout rewriting the manifest:viewvalue/chips/progress/list/tablewithfield,aggregate,columns,verbs, and the oldersectionblock as avalue.
Shared keys: params, where, alarm ({field, op, value}), staleAfterMinutes, trendDays, and meta — the datum on the right of the header, {field, unit, label} or {count, where, label}; without it the house shows the state.
Reader (reader + run.py)
Section titled “Reader (reader + run.py)”"reader": { "every": 30, "net": ["192.168.1.40"], "uses": "waterbot-imh", "key": "sensor", "fields": { "sensor": {"type": "text"}, "moisture": {"type": "number", "unit": "%"} } }Validated by jenny/board/micro.py::normalize_spec — a reader is the old notebook micro app — and run by jenny/apps/reader.py: every every minutes (at least 15) from the notebook clock (jenny/board/clock.py::run_reminders calls run_due_readers once per tick, before the notebooks), in python_exec’s namespace with the installation read-only, 20 s and at most 8 HTTP requests per run, network only through ctx.http to the hosts in net. run(ctx) returns a dict with exactly the declared fields, or — with a key — a list of them, one per series (ctx.wanted lists the params the placements need). ctx.data(collection) reads the app’s own records; a reader also runs right after a mutating storage action (schedule_reader, debounced), so an aggregating reader keeps the summary in step with the data. Readings go to data/readings.jsonl (a normal collection, compacted to the last 48 plus one per day for about 13 months), full rows to data/latest.json, state to data/state.json; three code errors in a row mark it broken, network failures never do.
Drafts
Section titled “Drafts”There is no approval step: an app is live as soon as it exists (the trial with Keep it was removed on 06/10/2026 — Jenny already has more reach than any app, so it stopped nothing and cost the user a tap). What remains is that an app changes only when the new version works (jenny/apps/drafts.py):
app action=draft/editwrite intoapps/.drafts/<slug>/(invisible toscan_apps: no tools, not in the App page), validate withload_app, refuse a stateful app without asummary, and run the reader once there. When everything passes,promote_draftmakes it the app — a new app takes the whole folder, an existing one gets the new code and keeps itsdata/— and the after-turn tool sync tells the WebUI. When something fails, the draft stays for the nexteditand the live app is untouched.- Fence: generic writes into an app’s code are refused by
workspace_policy.refuse_board_write→is_app_code_pathat every file-tool andpython_execwrite path, so every change goes through those checks;data/andAGENT.mdstay writable. - At startup
GatewayContainer._prepare_appspromotes any valid draft left over by a version that still had trials (promote_valid_drafts).
Placement and permission
Section titled “Placement and permission”jenny/board/placements.py finds where an app is placed: a card whose app is {slug, params} (the card is the app; jenny/board/view.py adds its slip, its real box and app.hasScreen, and a tap opens the screen; the strip’s size is stored on the card as size and kept in step by place.refresh_app_cards), or — only from older versions and migrated micro apps — a drawer section {kind: "app", app, params}. jenny/board/place.py::place_app is the one writer, shared by the UI and the app tool; with link it ties the new card to an existing one by a thread. board.section.add refuses kind: "app": an app never goes inside another card’s drawer. A storage write, or an http action that is not GET, from a notebook’s turn is allowed only for an app placed on that notebook’s board (jenny/apps/permissions.py::write_refusal); from the personal chat, and from the screen’s own routes, there is no such check.
Storage: JSONL collections
Section titled “Storage: JSONL collections”One collection = one data/<collection>.jsonl file, one JSON object per line. execute_storage_action (jenny/apps/storage.py) is the single place all five ops run through:
append—_new_record()assignsid(secrets.token_hex(6), 12 hex chars) andts(ISO-8601 UTC, second precision) automatically; never declare params namedidorts.query— reads the whole file, filters by exact equality on every declared param (no partial match, no operators), and returns at mostlimit(default 200) records plus acountof the total matches (which may exceed what’s returned).set/update/delete— all rewrite the whole file atomically viaatomic_write;updatemerges params into the existing record,setreplaces it wholesale (keeping the originaltsunless overwritten), bothseton a missing id anddelete/updateon a missing id are handled explicitly (seton a new id appends it;update/deleteon a missing id return HTTP-ish 404).- Size cap: 5 MB per collection (
DEFAULT_MAX_COLLECTION_BYTES = 5_000_000, overridable viaapps.max_collection_bytesinconfig.json), checked beforeappend,setandupdate; over the limit, the write fails with status 413. Onlydeleteis not size-checked. There is no automatic rotation or pruning — an app that only ever appends will eventually hit this wall. - Writes are refused in two agent contexts: a read-only turn, and a turn in a notebook’s conversation for an app not placed on that notebook’s board (
jenny/apps/permissions.py).queryalways works, so an app can still show its data. - Corrupt lines (bad JSON, non-object) are skipped silently on read, with only a
logger.warning— a hand-edited or partially-written collection degrades gracefully rather than failing the whole read. - Writes are serialized per-file with an
asyncio.Lockkeyed by path (_LOCKSinstorage.py), so concurrent actions on the same collection don’t interleave.
Response envelope by op (never the bare value — always unwrap the field you need):
op |
Resolves to |
|---|---|
append / set / update |
{"ok": true, "record": {...}} |
delete |
{"ok": true, "deleted": "<id>"} |
query |
{"ok": true, "records": [...], "count": N} |
HTTP actions: the proxy
Section titled “HTTP actions: the proxy”execute_http_action (jenny/apps/http.py) proxies onto server.baseUrl + path using httpx, independent of the browser↔gateway GET-only constraint (that’s a different leg of the request).
- Timeout: 20 s (
DEFAULT_TIMEOUT_S;apps.http_timeout_sinconfig.jsonoverrides it, within 1–120 s). - Response cap: 512 KB (
MAX_RESPONSE_BYTES), enforced while streaming — an oversized response fails with status 502 rather than being silently truncated. - Redirects are never followed (
follow_redirects=False) — a server can’t bounce the proxy to an address the SSRF check would otherwise block. - Target validation goes through
validate_app_server_target()(jenny/security/network.py): LAN addresses and the carrier-grade-NAT range Tailscale uses are allowed; loopback, link-local (cloud metadata) and0.0.0.0/8are blocked. A blocked target fails with status 403. - Response body:
{"ok": <bool from HTTP status>, "status": <int>, "data": <parsed JSON or raw text>}.
server.auth is rejected, not implemented
Section titled “server.auth is rejected, not implemented”There is no credential store for app servers. A manifest that declares "server": {"auth": {...}} is therefore rejected by the validator, and the app loads as broken with:
app.json: server.auth is declared but app-server credentials are not supported yet — remove the 'auth' block (every http action would be refused with 501)Rejecting at load time is deliberate: before, such an app passed validation, opened, and had every http action die with a 501 that only showed up when someone tried one. execute_http_action still refuses (status 501) any AppManifest that carries server_auth, as a last line for a manifest built by hand, but through the normal load path you never get that far. If you’re extending an app to talk to an authenticated server, it currently cannot be done through the manifest. Don’t advertise credential support to app authors until a credential store exists.
The SDK contract (sandboxed iframe)
Section titled “The SDK contract (sandboxed iframe)”app/index.html runs in a sandboxed <iframe> with only allow-scripts (no allow-same-origin, no allow-forms, no allow-modals). jenny-sdk.js is served from /html-mobile/assets/apps/jenny-sdk.js and must be loaded before your app code; it exposes a single global:
window.jenny = { slug, theme, lang, accent, action, discuss, navigate, back };| Member | Signature | Behavior |
|---|---|---|
jenny.action(name, params) |
async (string, object) => object |
Issues the transport GET described above, waits for the response, and throws an Error if the HTTP call fails or the envelope has ok: false. On success it resolves to the whole envelope, not the bare value — read .record / .records / .deleted yourself for storage actions, .data / .status for http ones. |
jenny.discuss(text) |
(string) => void |
postMessages a {type: 'jenny:discuss', slug, text} to the parent SPA, which switches the user to chat with the app’s context pre-filled. This is the only app→Jenny direction, and it’s always an explicit user-triggered hand-off — there’s no way for an app to talk to Jenny without it. |
jenny.navigate(url, state) / jenny.back() |
— | Push/pop one logical screen and report the new depth to the parent (jenny:nav-state), so the Android back button can pop one level inside the app before leaving it. The SDK deliberately never writes the browser history: entries pushed from the iframe land in the WebView’s joint session history, which the parent cannot prune, so they survived closeApp() as dead back presses. url is only a readable label — it is not applied to the address bar; carry data in state, which comes back on the synthetic popstate that back() dispatches. Open <dialog>s count towards the same depth, so back closes the topmost dialog before the screen underneath it. |
jenny:data-changed window event |
— | Dispatched whenever Jenny (not the app itself) mutates a storage collection the app might be showing, so the app can refetch and redraw. |
The data-changed push comes from the agent side: AppActionTool._notify_data_changed() (jenny/agent/tools/app_actions.py) publishes an OutboundMessage with metadata={"_app_data_changed": True, "app_slug": ...} after any agent-triggered storage mutation that isn’t a query — the SPA relays it into the iframe as postMessage({type: 'jenny:data-changed', slug}), and the SDK re-dispatches it as the jenny:data-changed CustomEvent your app listens for. It only fires for writes made while the app happens to be open; there’s no catch-up mechanism for changes made while the app was closed, so always fetch fresh data when the app opens rather than relying on the event alone.
Two optional helpers ship next to jenny-sdk.js in jenny/templates/ui/assets/apps/, served from the same /html-mobile/assets/apps/ path:
jenny-kit.css— the shared design system for apps: it carries its own copy of the SPA’s theme tokens (CSS custom properties do not cross the iframe boundary), which the SDK then overrides at runtime with the active theme’s palette.jenny-charts.js— a few tiny chart helpers (JennyCharts.line,.bars,.gauge) built on a globald3, so load d3 first (/html-mobile/assets/vendor/d3@7/d3.min.js). Colors come from the Jenny Kit variables, so the charts follow the theme.
Because the iframe has no allow-same-origin, its origin is opaque — that’s why auth travels as a ?token= query param baked into the iframe src rather than an Authorization header (a header would need a CORS preflight the GET-only server can’t answer).
That token is the app’s own, not the gateway’s: an HMAC of the per-install secret over the app’s slug (jenny/apps/token.py), which the gateway accepts on /apps/<slug>/… and /api/apps/<slug>/actions/… and nowhere else — not on any other /api/ route, not on another app’s routes, not on the WebSocket. Don’t try to reuse it for anything else; it won’t open it.
Sandbox rules that will actually break your app
Section titled “Sandbox rules that will actually break your app”- No
<form>— submission is blocked before thesubmitevent fires, soevent.preventDefault()can’t rescue it. Use a plain<button type="button">with a click handler. - No
alert()/confirm()/prompt()— the sandbox has noallow-modals; they silently do nothing. Build dialogs with<dialog>or plain markup. - Never call the actions endpoint with raw
fetch(), and never withPOSTor custom headers — always go throughjenny.action(). - No external hosts in
src/href— only same-origin gateway paths (/html-mobile/assets/...) are reachable; the device may be offline.
How <slug>_<action> tools appear to the agent
Section titled “How <slug>_<action> tools appear to the agent”Every declared action becomes a native tool the LLM can call directly — AppActionTool wraps one action with a name of <slug>_<action_name> (e.g. plants_water) and a JSON Schema built from action_param_schema(), which is the same schema used to validate the HTTP-side call.
Registration is kept in sync by AppToolsSyncer (jenny/agent/tools/app_actions.py), invoked once per agent turn rather than once at startup:
- It stats every
app.jsonunderworkspace/apps/; if nothing changed since the last sync, it’s a cheap no-op (onestat()per app). - On any change (new app, edited manifest, deleted app), it unregisters that app’s old tools and re-registers from the fresh manifest — no gateway restart needed. A broken app (bad JSON, invalid action) contributes zero tools rather than crashing the sync.
- Name collisions with a non-app tool are skipped with an explicit warning (
App tool '<name>' collides with an existing tool; skipped) — a deliberately gentler path than the built-in loader’s raise (see Write a tool), because an app manifest can be LLM-generated and imperfect; the sync must degrade gracefully rather than disturb the agent loop. Slugs keep their hyphens in the tool name (my-app→my-app_list), deliberately, somy-appandmy_appcan’t fold into each other. - This means Jenny can call
plants_water(and mutatedata/) even while the app is fully closed — a closed app is just unrendered HTML.
Testing
Section titled “Testing”Runtime unit tests live at tests/apps/ (mirroring jenny/apps/) — write manifests to a tmp_path fixture and call execute_action()/load_app() directly rather than going through the gateway. tests/agent/test_apps_tool_sync.py is the reference for exercising AppToolsSyncer: it writes an app.json, calls sync(registry), and asserts on the resulting tool names, tool identity across unchanged syncs (registry.get(name) is tool), and cleanup after deleting the app folder — copy that pattern if you change sync behavior. As with any tool, run ruff check jenny/ tests/, the pyright subset, and pytest -q before opening a PR — see Code style.
See also
Section titled “See also”- Mini-apps — the user-facing page (creating apps through chat, limits, deleting).
- Write a tool — how the built-in tool registry itself works.
- Tool reference — where app-generated tools sit alongside built-ins.
