Skip to Content
WorkspacesBuilding Tile Apps

Building Tile Apps

A tile app is a mini-app that runs in its own frame inside a workspace tile: a web UI (and optionally a server-side container) that reaches the workspace only through permissions you grant. This page is the developer contract: the manifest schema, the permission taxonomy, the validation rules, and the local build/test loop you use before submitting an app for review.

The whole flow is offline-first: you author and test with yolo tileapp validate and yolo tileapp dev (no auth, no network), then sign + publish when you’re ready for review.

The manifest

Every app is one manifest.json. Required fields:

{ "id": "my-app", "version": "1.0.0", "displayName": "My App", "publisher": "acme", "description": "What it does.", "ui": { "icon": "widgets", "color": "#58a6ff", "label": "My App" }, "surface": { "kind": "iframe", "entry": "index.html" }, "permissions": { "required": [], "optional": [] } }
FieldRule
idlowercase kebab-case slug, 2–64 chars (^[a-z0-9][a-z0-9-]{1,63}$)
versionsemver (1.0.0, 1.2.3-beta.1)
displayName, publisher, descriptionnon-empty strings
ui{ icon, color, label } — all non-empty strings
surface{ kind: "iframe", entry: "index.html" } — iframe is the only v1 kind; entry is the document served from your bundle
permissions.requiredarray (each must accept at install)
permissions.optionalarray (the user toggles these; your app must degrade gracefully if declined)

Optional blocks: surface.tilePrefersSize ({ rowSpan, colSpan }), runtime/image (server-side apps — see below), and the store-listing fields category, screenshots, changelog, featured, pricing.

Screenshots

The store shows a preview image on your app’s card and a gallery in its detail view. An app without one still lists — it falls back to a placeholder tinted with your ui.color — but the screenshot is what tells someone what they’re installing before they grant it permissions.

{ "screenshots": [ "media/01-main.webp", // shipped in your bundle "https://cdn.example.com/myapp/2.webp" // hosted by you ] }
FormWho can use itHow it gets there
media/<file>pure-UI apps (personal + first-party)put it in a media/ subdirectory of your bundle dir — publish walks subdirectories and uploads it; the platform serves it at /v1/tileapps/<appId>/media/<file>
https://…anyone, and the only option for runtime appspassed through untouched

A runtime app publishes an OCI image and never gets a bundle, so it has nowhere to put a media/ file — publish rejects that combination rather than let you ship a listing whose images 404. http:// is rejected too: the store renders on an https page, where a mixed-content image is silently blocked by the browser.

Format — 1280×800 (16:10) webp (or png), ≤ 400 KB each, 1–6 images, first is the hero. Capture at 2× and downscale; the card crops to roughly 320 px wide, so anything smaller than ~12 px type is unreadable there. Bundles cap at 100 files / 3 MB total, so budget accordingly.

Content — show the app doing its job on realistic data. An empty state, a spinner, or a permission prompt is a wasted slot, and a screenshot of a feature the app doesn’t have will be rejected at review.

Uploading them:

yolo tileapp media push ./tileapp.json # additive — uploads ./media/*.webp and *.png only yolo tileapp publish ./tileapp.json --personal # or ship them with a full publish

media push is the one to reach for on an app that’s already live: publish replaces the entire bundle (so it needs every file present locally), while media push writes just the images and leaves the rest alone. The upload decodes each file and rejects anything that isn’t a 16:10 webp/png between 640 and 2560 px wide, so a wrong-aspect or mislabelled image fails at upload rather than letterboxing in the store.

Validate before you publish:

yolo tileapp validate ./tileapp.json --personal # --personal if it's a personal app

Without --personal the linter applies the stricter partner rules (absolute URLs only), since it can’t otherwise tell which kind of manifest it has.

Pure-UI vs runtime apps

  • Pure-UI — just a static surface bundle (HTML/CSS/JS). Omit runtime and image. The platform serves the bundle; there is no server process. This is the simplest app and the only kind yolo tileapp dev serves locally.
  • Runtime — ships an OCI image ({ ref, tag, digest }); the platform launches the container and proxies your surface to its runtime.port. A manifest with runtime must carry an image (recommended) or a legacy runtime.exec. Any runtime app must pin image.digest to an immutable content digest (sha256:<64 hex>) — publish rejects a tag-only image (DIGEST_REQUIRED), and yolo tileapp validate enforces the same. (Self-serve/unverified publishers are further restricted to pure-UI apps until verified.)

Permissions

Permissions are plain strings, brokered server-side on every call. Recognized shapes:

ShapeExampleMeaning
fs.read:<scope> / fs.readwrite:<scope>fs.read:workspaceread / read-write a filesystem scope
net:<host>net:api.example.comegress to a specific host — no wildcards
mcp:<scope>mcp:studio.get_workspace_contextcall a workspace MCP scope
llm.invokellm.invokeinvoke an LLM and list the model catalog (llm.discover). One permission, no provider or model in the string — you choose the model per call. Bounded by a monthly spend cap and account credits — see Tile App Models & Credits
media.upload:<kind> / media.download:<kind>media.upload:videomedia transfer
secret:<KEY_NAME>secret:OPENAI_API_KEYa brokered credential (preset key names only)
notifications.send:<destination>notifications.send:workspacesend app notifications — global (top-level notification bell) or workspace (beside the workspace’s command bar); normal tier
device.<capability>device.screen-capturedevice capability (future)

Each permission carries a tier (normal → sensitive → restricted → internal) shown on the consent screen. internal scopes (e.g. work.*) are never grantable to apps. Combination rules can escalate the effective tier (e.g. fs.read:workspace + network egress), and secret: + a foreign-host net: is rejected outright (credential-exfil). Request the least you need.

Permission ceiling by verification level. Publish enforces a ceiling on the highest tier your publisher may request (PERMISSION_CEILING):

Verification levelMay request up to
unverifiednormal only — no sensitive/restricted/internal (so no fs.read:workspace, no secret:*), and pure-UI apps only
verifiedup to restricted (sensitive/restricted are subject to manual review)
first-partyup to restricted

internal-tier permissions (e.g. mcp:work.*, raw secret:*, fs.readwrite:workspace) are never accepted via publish for any publisher — they’re curated/first-party only and ship through the platform seed path, not the marketplace submission API.

Using a credential (brokered)

A marketplace app never receives a raw secret: value in its environment. Instead, a granted credential is held by an in-pod credential gateway: your app calls the vendor through it and the gateway attaches the secret server-side, so your code never sees the bytes. At launch the platform injects YOLO_CREDENTIAL_GATEWAY (a localhost base URL with a per-tile token); point your vendor SDK’s base URL at ${YOLO_CREDENTIAL_GATEWAY}/v/<vendorId> and call it normally:

// e.g. for an OpenAI-style client (vendorId 'openai'): const client = new OpenAI({ // The gateway forwards the path verbatim to the vendor host, so include the // vendor's own API prefix (OpenAI serves under /v1). baseURL: `${process.env.YOLO_CREDENTIAL_GATEWAY}/v/openai/v1`, apiKey: 'brokered', // placeholder: the SDK requires one, but the gateway STRIPS // your Authorization and attaches the real key (api.openai.com only) });

The gateway maps …/v/<vendorId>/<path> → https://<vendorHost>/<path> (path forwarded as-is), so address it exactly as you would the vendor, minus the host. Supported vendors are a curated catalog (static-credential APIs: OpenAI, Anthropic, Gemini, ElevenLabs, Stripe, Cloudflare, GitHub, DigitalOcean).

llm.invoke runtime contract

What actually happens when your app calls llm.invoke through the broker:

  • Request — the args are a thin OpenAI /v1/chat/completions body. stream is forced to false server-side (the broker’s request/response bridge can’t relay SSE), and stream_options is dropped with it.
  • Your model is honored. "provider/model" (an id from llm.discover) is a hard pin; a bare model id is resolved against the model catalog; absent or "auto" lets the platform route by activity and tier. The model that actually served the call comes back as payload.model.
  • Response — the router’s completion body is returned as payload, normalized in exactly one way: if a reasoning model finishes a normal stop (no tool calls, not truncated) with choices[].message.content empty/null and the answer stranded in message.reasoning_content, the broker promotes it into content (leaving reasoning_content in place). So payload.choices[0].message.content is the field to read; it stays empty only when the model genuinely produced no visible answer (tool-call turns, finish_reason: "length").

Your model field is honored. A model string either takes effect as a pin or comes back with a payload.modelFallback note. Full rules, discovery filters, error codes and pricing: Tile App Models & Credits.

LLM cost bounds

llm.invoke runs through the platform LLM gateway, so it costs real money, and two independent bounds apply — both must pass.

  • Per-app spend cap — a per-user, per-app monthly USD cap (default $10/mo; the user can change it in the Marketplace — filter to Installed and open the app’s detail). At the cap the broker denies with spend-cap-reached (HTTP 402) until the next month or the user raises it. Enforced fail-closed: a call that can’t be verified under budget is denied. This bounds one app’s blast radius.
  • Account credits — an account-wide balance (the monthly tier allowance) debited per call, denying with insufficient-credits (HTTP 402). This bounds the account, across every app. Credit enforcement is currently off by default — spend is tracked and ledgered but not yet blocked; design for the deny anyway. See Tile App Models & Credits.

Design your app to degrade gracefully on a denied LLM call either way.

Brokered credentials (the secret: path above) are separate money: the credential is bound to one vendor host — it can’t be sent anywhere else — and every use is metered + audited (the gateway logs request metadata, never your secret or body). Non-HTTP secrets (DB URIs) and OAuth/request-signing vendors aren’t brokerable. First-party/curated apps keep raw env injection.

The bridge and tokens at runtime

Your iframe talks to the host only via postMessage — the wire contract lives in @yolo-labs/app-sdk (npm i @yolo-labs/app-sdk; source on GitHub , where examples/minimal-app.html is the canonical minimal client, and the platform demo app hello in containers/config/tileapps/ exercises the full flow: render, consent, broker allow/deny, styling/design tokens).

Two facts that save you a debugging session:

  • Your app never receives a token. The host attaches the user credential in trusted parent code; the iframe has no token API, so there is nothing to decode. Don’t design around reading workspaceId (or anything else) out of a JWT — the sanctioned source is app.getContext() (@yolo-labs/app-sdk ≥ 0.5.0): the host answers with { workspaceId, tileId, appId, theme } from its own state, no server round-trip.
  • Delegated tokens the platform mints on your behalf are short-lived by design: 300 s default / 600 s max, and any restricted-exposure scope on a tile-app principal is clamped to 60 s. Server-side callers must mint per use, never cache.
  • Mint board and checklist scopes in their own token. Minted tokens are pinned to your app’s own tile(s); a granted studio.kanban_* or studio.checklist_* scope additionally pins the workspace’s kanban or checklist tiles — but only when every scope in that mint belongs to those families (plus studio.list_tiles). Mixing them with another tile-touching scope (studio.send_to_tile, studio.remove_tile, …) in one mint withholds the widening and your board and list calls will 403 — mint one token per scope family instead.

Validation taxonomy

yolo tileapp validate reports every problem at once. The error strings match exactly what the publish endpoint enforces, so a clean local validate means a clean submission. Representative messages:

  • id must be a lowercase kebab-case slug (2-64 chars)
  • version must be semver (e.g. 1.0.0)
  • surface.kind must be 'iframe' (v1) / surface.entry is required
  • permissions.required must be an array
  • unrecognized permission: '<p>'
  • net permission must name a specific host (no wildcards): '<p>'
  • runtime requires either \image` (OCI image, recommended) or `runtime.exec` (bare-process override)`
  • pricing.priceUsdMonthly is required (> 0) for a subscription

The offline validator checks permission shape. The exact set of valid mcp: scopes and secret: key names is confirmed server-side at publish — a well-formed but unknown scope/key passes the local lint and is rejected at submission if it isn’t real.

Bundle layout

For a pure-UI app, your surface.entry must exist on disk. validate/dev look for it in this order:

  1. --bundle-dir <dir> (explicit override)
  2. <manifest-dir>/bundles/<id>/ (the registry layout)
  3. <manifest-dir>/ (a flat project where index.html sits beside the manifest)
my-app/ ├── manifest.json └── bundles/ └── my-app/ └── index.html # = surface.entry

The local dev loop

# 1. Lint the manifest + bundle (offline, no auth) yolo tileapp validate manifest.json # 2. Serve the bundle + a MOCK broker locally, then open it in a browser yolo tileapp dev manifest.json --port 4000 # open http://127.0.0.1:4000/index.html # broker POST http://127.0.0.1:4000/__yolo/broker (mock: allow-all)

yolo tileapp dev validates first (it refuses to serve an invalid manifest), then serves your bundle with two helper routes:

  • POST /__yolo/broker — a mock Capability Broker that returns the same shape as production: { "ok": true, "payload": { "echo": <your request> } } on allow, or { "ok": false, "reason": "permission-not-granted", "message": … } with HTTP 403 on deny. Allow-all by default; pass --deny to deny every request so you can test your app’s deny/error-handling path against the real contract.
  • GET /__yolo/manifest — returns your manifest (handy for the app to read its own declared permissions).

dev serves pure-UI bundles only. For a runtime/image app, run your container yourself and preview it directly — validate still lints the manifest.

Submitting for review

Not yet open to external publishers. Marketplace submission (yolo tileapp sign / yolo tileapp publish to the store) is not enabled for third-party publishers in production yet — the flow below describes how it works once partner publishing opens. Personal apps are live today: publish with yolo tileapp publish --personal (see Personal apps) to build and run your own apps without waiting.

When the local loop is clean:

yolo tileapp sign manifest.json --publisher <your-publisher-id> # platform KMS-signs the manifest yolo tileapp publish manifest.json # submits a quarantine release (default: stable channel)

sign/publish are user-authenticated (set YOLO_API_TOKEN + YOLO_COMMON_API_URL) and run off-pod. A submission lands as a submitted release — never installable until an operator reviews and promotes it. The review pipeline runs the same static gates as validate plus a manual review; on approval the release goes live and the app appears in the store with your publisher’s verification badge (YOLO Labs / Verified publisher / Unverified publisher). Unverified-publisher apps show users an extra acknowledgement at install.

Personal apps

Every YOLO Studio user can create personal tile apps — apps that run only for you, in your own workspaces, with no marketplace submission or review required. Personal apps use the same manifest format, permission model, and yolo tileapp dev loop as marketplace apps; the only difference is in how they’re published.

The manifest file for a personal app is tileapp.json (same schema as manifest.json — the name is just convention for the init scaffold).

Creating a personal app

# 1. Scaffold a new app (creates ./<name>/tileapp.json + a renderable index.html, # app.js for your JavaScript, and a media/ dir for store screenshots) yolo tileapp init my-clock # 2. Develop and validate cd my-clock yolo tileapp validate tileapp.json yolo tileapp dev tileapp.json --port 4000 # 3. Publish to your personal registry (no signing step) yolo tileapp publish tileapp.json --personal # → prints your appId (pa-<userId>-<localId>)

A personal app’s id can be at most 32 characters, since the platform prefixes it with pa-<userId>-.

publish --personal uploads your bundle to your account as an owner-only personal app. The app is immediately installable — no review queue, no approval wait.

After publishing, drop it as a tile in your workspace:

# From the workspace MCP (or YOLO Assistant): studio.install_app # appId from the publish step (+ optional acceptedOptional) studio.create_app_tile # appId (+ optional title, desktopId, tileId) # The workspace comes from the agent's token — neither tool takes a workspaceId.

These two scopes are internal-exposure — they can never be granted to a tile app, but session agents whose registry entry lists them (claude, codex, opencode-yolo, muse, and YOLO Assistant’s vibe-assist) can mint them, so those agents can call them directly from inside your pod. Other agents’ tokens don’t carry these scopes. The same steps are also available on the user-JWT REST surface:

# 1. Install (grant) the app into a workspace curl -X POST "$YOLO_COMMON_API_URL/v1/tileapps/<appId>/install" \ -H "Authorization: Bearer $YOLO_API_TOKEN" -H 'content-type: application/json' \ -d '{"workspaceId":"<workspaceId>"}' # 2. Place the tile — note this REST route REQUIRES a client-generated tile id # (unlike the MCP tools, which auto-generate one) curl -X POST "$YOLO_COMMON_API_URL/v1/workspaces/<workspaceId>/tiles" \ -H "Authorization: Bearer $YOLO_API_TOKEN" -H 'content-type: application/json' \ -d '{"id":"app-<appId>-1","type":"app","name":"My App","app":{"appId":"<appId>","version":"1.0.0"}}'

From a terminal, the yolo CLI wraps the same two calls and generates the tile id for you:

yolo tileapp install <appId> --workspace <workspaceId> # [--accept-optional <perm>] (repeatable) yolo tileapp add-tile <appId> --workspace <workspaceId> # [--name <name>] [--version <v>]

You can also create and manage personal apps entirely through the MCP tool studio.create_personal_app (no files on disk required — pass the manifest JSON directly).

REST endpoints for the personal app registry: GET /v1/tileapps/personal, POST /v1/tileapps/personal, PATCH /v1/tileapps/personal/:id, DELETE /v1/tileapps/personal/:id.

What you can and can’t request

Personal apps run as you, in your own pod, so they are not held to the unverified-publisher normal-tier ceiling: fs, secret:, net:, llm.invoke, and media permissions are all allowed. The one structural block is that any mcp: scope whose exposure is internal is rejected. This covers work.*, vibe.*, studio.create_agent_tile, studio.create_personal_app, and similar orchestration scopes. Standard mcp: scopes (studio.publish_artifact, studio.list_artifacts, studio.get_artifact, etc.) are fine.

Personal apps are pure-UI by default. Runtime (container) personal apps are gated behind a platform feature flag; where it’s off, publish rejects a personal manifest carrying runtime or image. Where it’s on, a runtime personal app must be published via POST /v1/tileapps/personal/publish-image (not the direct create routes), must ship a digest-pinned OCI image in your own personal-registry namespace, and may not use a bare-process runtime.exec.

Differences from marketplace apps

Personal appMarketplace app
Who can use itYou onlyAny YOLO Studio user
Review requiredNoYes (operator review before live)
Signing stepNot requiredyolo tileapp sign before publish
Permission ceilingAny tier except internal MCP scopes; pure-UI unless runtime personal apps are enabled in the environmentDepends on verification level
Publisher fieldForced to personal:<userId>Your registered publisher id
Appears in MarketplaceOnly to you (labeled personal app)To everyone, once partner publishing opens and the app is approved

Personal apps are not listed for other users. They appear only in your own Marketplace view, labeled personal app. Sharing a personal app with other users isn’t available yet. Partner publishing is early access only, by application at yolo.studio/developers.

See also: Tile App Models & Credits, Agent Registry, Workspace MCP Server.

Last updated on