Skip to content

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.

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.

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 included

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

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

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" }
]
}

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_slip validates 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 their max (≤ 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’s references/manifest.md.
  • Controls (checklist toggle, buttons, switches, counter) may name only actions the app declares, and only ones a tap can fill: an action with other required params is refused at load.
  • jenny/apps/slip_view.py::slip_view computes what each block draws, from the readings or the named collections filtered by the placement’s params/where. The strip’s state (ok, stale, alarm, empty, broken) is summary_view’s, the same one the chat and the clock read.
  • photos shows the newest images in the app’s data/media/. That folder is the only part of data/ the house can read, through GET /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 a blob:.
  • Without blocks the older forms still work, translated into blocks by slip_spec.legacy_blocks without rewriting the manifest: view value / chips / progress / list / table with field, aggregate, columns, verbs, and the older section block as a value.

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": { "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.

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 / edit write into apps/.drafts/<slug>/ (invisible to scan_apps: no tools, not in the App page), validate with load_app, refuse a stateful app without a summary, and run the reader once there. When everything passes, promote_draft makes it the app — a new app takes the whole folder, an existing one gets the new code and keeps its data/ — and the after-turn tool sync tells the WebUI. When something fails, the draft stays for the next edit and the live app is untouched.
  • Fence: generic writes into an app’s code are refused by workspace_policy.refuse_board_write → is_app_code_path at every file-tool and python_exec write path, so every change goes through those checks; data/ and AGENT.md stay writable.
  • At startup GatewayContainer._prepare_apps promotes any valid draft left over by a version that still had trials (promote_valid_drafts).

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.

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() assigns id (secrets.token_hex(6), 12 hex chars) and ts (ISO-8601 UTC, second precision) automatically; never declare params named id or ts.
  • query — reads the whole file, filters by exact equality on every declared param (no partial match, no operators), and returns at most limit (default 200) records plus a count of the total matches (which may exceed what’s returned).
  • set / update / delete — all rewrite the whole file atomically via atomic_write; update merges params into the existing record, set replaces it wholesale (keeping the original ts unless overwritten), both set on a missing id and delete/update on a missing id are handled explicitly (set on a new id appends it; update/delete on a missing id return HTTP-ish 404).
  • Size cap: 5 MB per collection (DEFAULT_MAX_COLLECTION_BYTES = 5_000_000, overridable via apps.max_collection_bytes in config.json), checked before append, set and update; over the limit, the write fails with status 413. Only delete is 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). query always 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.Lock keyed by path (_LOCKS in storage.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}

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_s in config.json overrides 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) and 0.0.0.0/8 are blocked. A blocked target fails with status 403.
  • Response body: {"ok": <bool from HTTP status>, "status": <int>, "data": <parsed JSON or raw text>}.

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.

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 global d3, 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 the submit event fires, so event.preventDefault() can’t rescue it. Use a plain <button type="button"> with a click handler.
  • No alert()/confirm()/prompt() — the sandbox has no allow-modals; they silently do nothing. Build dialogs with <dialog> or plain markup.
  • Never call the actions endpoint with raw fetch(), and never with POST or custom headers — always go through jenny.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.json under workspace/apps/; if nothing changed since the last sync, it’s a cheap no-op (one stat() 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, so my-app and my_app can’t fold into each other.
  • This means Jenny can call plants_water (and mutate data/) even while the app is fully closed — a closed app is just unrendered HTML.

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.

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

Jenny · code underAGPL-3.0 · name and mascot undertrademark

No cookies, no trackers, no analytics. This page loads nothing from a third party.