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": [] }
}| Field | Rule |
|---|---|
id | lowercase kebab-case slug, 2–64 chars (^[a-z0-9][a-z0-9-]{1,63}$) |
version | semver (1.0.0, 1.2.3-beta.1) |
displayName, publisher, description | non-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.required | array (each must accept at install) |
permissions.optional | array (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
]
}| Form | Who can use it | How 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 apps | passed 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 publishmedia 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 appWithout --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
surfacebundle (HTML/CSS/JS). Omitruntimeandimage. The platform serves the bundle; there is no server process. This is the simplest app and the only kindyolo tileapp devserves locally. - Runtime — ships an OCI
image({ ref, tag, digest }); the platform launches the container and proxies yoursurfaceto itsruntime.port. A manifest withruntimemust carry animage(recommended) or a legacyruntime.exec. Any runtime app must pinimage.digestto an immutable content digest (sha256:<64 hex>) — publish rejects a tag-only image (DIGEST_REQUIRED), andyolo tileapp validateenforces 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:
| Shape | Example | Meaning |
|---|---|---|
fs.read:<scope> / fs.readwrite:<scope> | fs.read:workspace | read / read-write a filesystem scope |
net:<host> | net:api.example.com | egress to a specific host — no wildcards |
mcp:<scope> | mcp:studio.get_workspace_context | call a workspace MCP scope |
llm.invoke | llm.invoke | invoke 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:video | media transfer |
secret:<KEY_NAME> | secret:OPENAI_API_KEY | a brokered credential (preset key names only) |
notifications.send:<destination> | notifications.send:workspace | send app notifications — global (top-level notification bell) or workspace (beside the workspace’s command bar); normal tier |
device.<capability> | device.screen-capture | device 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 level | May request up to |
|---|---|
unverified | normal only — no sensitive/restricted/internal (so no fs.read:workspace, no secret:*), and pure-UI apps only |
verified | up to restricted (sensitive/restricted are subject to manual review) |
first-party | up 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/completionsbody.streamis forced tofalseserver-side (the broker’s request/response bridge can’t relay SSE), andstream_optionsis dropped with it. - Your
modelis honored."provider/model"(anidfromllm.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 aspayload.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) withchoices[].message.contentempty/null and the answer stranded inmessage.reasoning_content, the broker promotes it intocontent(leavingreasoning_contentin place). Sopayload.choices[0].message.contentis the field to read; it stays empty only when the model genuinely produced no visible answer (tool-call turns,finish_reason: "length").
Your
modelfield is honored. Amodelstring either takes effect as a pin or comes back with apayload.modelFallbacknote. 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 isapp.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_*orstudio.checklist_*scope additionally pins the workspace’s kanban or checklist tiles — but only when every scope in that mint belongs to those families (plusstudio.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 requiredpermissions.required must be an arrayunrecognized 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 andsecret: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:
--bundle-dir <dir>(explicit override)<manifest-dir>/bundles/<id>/(the registry layout)<manifest-dir>/(a flat project whereindex.htmlsits beside the manifest)
my-app/
├── manifest.json
└── bundles/
└── my-app/
└── index.html # = surface.entryThe 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--denyto 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 publishto 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 withyolo 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’svibe-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 app | Marketplace app | |
|---|---|---|
| Who can use it | You only | Any YOLO Studio user |
| Review required | No | Yes (operator review before live) |
| Signing step | Not required | yolo tileapp sign before publish |
| Permission ceiling | Any tier except internal MCP scopes; pure-UI unless runtime personal apps are enabled in the environment | Depends on verification level |
| Publisher field | Forced to personal:<userId> | Your registered publisher id |
| Appears in Marketplace | Only 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.