Skip to content

Writing a Mini-app (Jenny App)

A Jenny App is a declarative manifest plus a static HTML/JS frontend — this page is the technical contract for hand-writing or extending one, distilled from .agent/jenny-apps.md. 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, never by hand. 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
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 websocketshttp11 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
├── app/
│ └── index.html # the UI (HTML/JS only, no per-app Python)
└── data/ # JSONL collections

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 Apps grid 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://...", "auth": {...}} — required if any action has kind: "http".
actions yes Non-empty array (see 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/hyphens; 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, from .agent/jenny-apps.md:

{
"name": "Piante",
"description": "Plant monitoring: humidity, status, care log",
"server": { "baseUrl": "http://192.168.1.50:8080", "auth": { "secretRef": "piante_token" } },
"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" }
]
}

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 and set only; over the limit, the write fails with status 413. update and delete are not size-checked, since they can only shrink or replace. There is no automatic rotation or pruning — an app that only ever appends will eventually hit this wall.
  • 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, clamped to 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 are allowed, loopback and cloud-metadata addresses are 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 fail-closed, not implemented

Section titled “server.auth is fail-closed, not implemented”

A manifest can declare "server": {"auth": {"secretRef": "..."}}, but there is currently no credential store behind it. If manifest.server_auth is set, execute_http_action refuses the call outright:

HttpActionError("server.auth is declared but no credential store is configured; "
"the action is refused until app-server credentials are supported", status=501)

This was a deliberate choice over the alternative (silently calling the endpoint without credentials, which either loses the request or returns a confusing 401): fail closed and say why. If you’re extending an app to talk to an authenticated server, it currently cannot be done through the manifest’s auth field — the action will always 501. Don’t advertise credential support to app authors until jenny/security/workspace_access.py’s credential-store hook is actually implemented.

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() Thin wrapper over history.pushState/history.back() that also reports nav depth to the parent (jenny:nav-state), so the Android back button can pop one level inside the app before leaving 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.

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

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