A tile is a web page.
Seriously, that's it.
If you can build an HTML page, you can build for your desk. HTML and CSS for the view, an optional Python worker for data, and a simple state bus in between. No proprietary framework, no device flashing, no build step.
What a tile is
A tile is one full-screen card on the DeskProxy display. The user swipes left and right on the device to move between tiles. A tile is just a folder with a manifest and a web page:
The mental model
- Your page renders on your computer, not on the device. The DeskProxy app renders
index.htmlin a sandboxed browser engine, captures it about ten times a second, and streams each frame to the display over the USB cable. The device is a screen; your Mac does the thinking. - There is no request/response between the page and the worker. Both talk only to the state bus — a retained publish/subscribe key-value store. The worker can crash and the page keeps showing the last values; the page can reload and the worker never notices.
- Taps arrive as real DOM
clickevents.<button>,addEventListener,:active— all standard. Swipes never reach your page; the system uses them for navigation.
This whole guide is self-contained. Paste it into your model with your tile idea and ask it to produce the files — a copy-paste prompt is in Pitfalls.
Quick start
The smallest possible tile is two files.
tile.json
{ "id": "hello-world", "name": "Hello World", "version": "1.0.0" }
index.html
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
html, body { margin:0; height:100%; background:#0D0D0D; color:#fff;
font-family:-apple-system, Helvetica, sans-serif;
display:flex; align-items:center; justify-content:center; }
h1 { font-size:48px; }
</style></head>
<body>
<h1>Hello DeskProxy!</h1>
</body>
</html>
Zip the two files (see Package & install), open the DeskProxy app's Extensions panel, click Import ZIP…, confirm the permission summary — done. The tile appears in the rotation immediately.
The app's live mirror shows exactly what the device shows. You can build and preview an entire tile with no device connected.
Files in a tile
| File | Required | Purpose |
|---|---|---|
tile.json | Yes | Manifest: identity, permissions, config, swipe behaviour. |
index.html | Yes | The UI. Loads in a sandboxed browser engine with window.desknode injected. May reference sibling files (style.css, app.js, images) via relative paths. |
worker.py | — | Optional Python backend. Disabled by default; the user must opt in. |
| anything else | — | Assets, a README, etc. Served to your page from your tile folder only — no access to the host filesystem. |
Your page is served from a locked-down internal scheme. It can load its own sibling files but cannot read file:// URLs or anything outside the tile folder.
Display & input constraints
Read this first — most mistakes happen here. The device is small, fixed, and tap-only.
| Constraint | Rule |
|---|---|
| Viewport | desknode.width × desknode.height — 480×320 today. Read the constants; don't hardcode — a taller display is coming. |
| Scrolling | Impossible. The runtime forces overflow: hidden and pins the page to the viewport. Anything past the edge is clipped and invisible — design to fit. |
| Input | Single taps only, delivered as a DOM click. No hover, scroll, keyboard, drag, pinch, double-tap or long-press. |
| Swipes | Consumed by the system for tile navigation. A swipe up/down can cycle your config if you declare it (see Touch & swipe). |
| Frame rate | The page is captured ~10×/s and only changed regions are sent. CSS animations work — keep them small. A full-screen animation forces full-frame transfers. |
| Touch targets | A fingertip on a 4″ screen — make tappable elements ≥ 48 px tall. |
| Readability | Read at arm's length. Big type, high contrast, dark backgrounds. |
| Background | Always set body background explicitly. Omit it (or error during load) and you get a bright white screen. |
tile.json — the manifest
{
"id": "stock-watch",
"name": "Stock Watch",
"version": "1.0.0",
"author": "you",
"description": "AAPL price at a glance",
"runtime": "html",
"entry": "index.html",
"background": false,
"refresh_rate": 0.0,
"default_config": { "symbol": "AAPL" },
"swipe_up": { "field": "symbol", "options": ["AAPL", "MSFT", "NVDA"] },
"egress": ["query1.finance.yahoo.com"],
"secrets": { "query1.finance.yahoo.com": ["YAHOO_FINANCE_KEY"] }
}
| Field | Default | Meaning |
|---|---|---|
id (req) | — | Unique, lowercase, no spaces (hyphens ok). Becomes your state-bus namespace tiles.<id>.*, your install folder, and your key prefix. |
name (req) | — | Human-readable name shown in the app and on the loading placeholder. |
version (req) | — | Semver string. |
runtime | "html" | Only "html" is supported for new tiles. |
entry | "index.html" | The page to load. |
background | false | true → your worker (if enabled) runs even while your tile is off-screen. false → only while your tile is active. |
refresh_rate | 0.0 | Minimum seconds between frames sent to the device. 0 = every tick (~10 Hz). A clock that changes once a minute can use 30.0 to save bandwidth. |
default_config | {} | Defaults for your config. Merge order (last wins): defaults → user's saved config → swipe override. Arrives via desknode.config / desknode.onConfig. |
swipe_up / swipe_down | null | A device swipe cycles field through options and pushes the new config to your page. Use for sub-tile variants (themes, symbols). |
egress | [] | Allow-list of hostnames your tile may contact. Anything not listed is hard-blocked. |
secrets | {} | Map of hostname → [SECRET_NAME, …]. Injected into requests only when they go to that host. |
author, description | — | Informational. Shown in the install dialog. |
Permissions & the security sandbox
Tiles are community-shipped and run on someone's personal Mac, so the runtime is sandboxed: every capability that reaches outside the page must be declared in tile.json. What you declare is exactly what the user is shown at install time, and the runtime enforces it — declarations are not advisory.
What the user sees when installing your tile
- 🌐 Network — the list of
egresshosts, or "none (offline tile)" if empty. - 🔑 Secrets — which secret names you request, and the single host each one may be sent to.
- ⚠️ Python worker — shown only if
worker.pyis present, flagged as host code and disabled until the user opts in.
Keep the list minimal and honest — it's the first thing a user judges your tile by.
What is actually enforced
| Capability | Declared in | Enforcement |
|---|---|---|
| Network egress | egress | Every network request — desknode.fetch, plain fetch(), XHR, images, fonts — to any host not listed is blocked. Navigating away from your page is blocked. Redirects off the allow-list are blocked. |
| API keys | secrets | Stored in the OS keychain, never in your files. Injected into a request only when it targets the host the secret is mapped to. A secret mapped to host A is never sent to host B. |
| Host code | presence of worker.py | Runs only if the user explicitly opts your tile in. The HTML page itself has no filesystem access. |
An HTML-only tile with egress: [] can do nothing but render — no network, no disk, no other tile's private data beyond the shared state bus. Most tiles should stay there.
index.html & the desknode API
The app injects window.desknode before any of your scripts run. In a normal desktop browser it doesn't exist, so include this dev stub — it's harmless in production (the real desknode is already defined and wins):
<script>
if (!window.desknode) { // dev stub — lets the page run in a browser
window.desknode = {
width: 480, height: 320, tileId: "my-tile", config: {},
ready: function (cb) { cb(); },
get: function () { return undefined; },
subscribe: function () {},
publish: function (k, v) { console.log("publish", k, v); },
onConfig: function (cb) { cb(this.config); },
fetch: function () { return Promise.reject(new Error("no daemon (dev stub)")); }
};
}
</script>
API reference
desknode.width // number — viewport width in px (480 today)
desknode.height // number — viewport height in px (320 today)
desknode.tileId // string — your id from tile.json
desknode.config // object — merged config (defaults → user → swipe override)
desknode.ready(cb)
// cb() fires ONCE, after the bridge is connected AND the retained state
// snapshot is seeded. Do all initial get()/subscribe() calls inside ready() —
// before it fires the cache is EMPTY.
desknode.get(key)
// → last known value of a state-bus key, or undefined. Call after ready().
desknode.subscribe(pattern, cb)
// pattern: glob ("system.temp_c", "system.*", "tiles.stocks.*").
// cb(key, value) fires immediately for every already-known match (you never
// start blank), then on every update.
desknode.publish(key, value)
// Publishes under tiles.<yourId>.<key> — the prefix is added for you. You
// cannot write into system.* or another tile's namespace. value: any JSON
// value, ≤ 16 KB, ≤ 20 publishes/sec (publish on change, not in a hot loop).
desknode.onConfig(cb)
// cb(config) on every config change, including swipe-up/down cycling.
desknode.fetch(url, options) → Promise
// Secure HTTP proxy through the app. Resolves to a MINIMAL object:
// { ok: true, text: () => Promise<string>, json: () => Promise<any> }
// No .status / .statusText / .headers. REJECTS with an Error on any failure
// (blocked host, network error, non-2xx). Use try/catch or .catch().
Don't reach for response.status or response.headers — they aren't there. Success = the promise resolved; failure = it rejected with an Error whose message explains why.
The state bus
A retained key-value pub/sub channel shared by the app and all tiles.
| Pattern | Writer | Available now |
|---|---|---|
system.* | the app | system.temp_c, system.temp_f, system.humidity (onboard sensor, ~10 s cadence). More keys appear over time. |
tiles.<id>.* | tile <id> | Whatever each tile publishes. Your keys land at tiles.<yourId>.<key>. |
- Retained: subscribing replays the last known value immediately, then live updates follow. You never start blank for a key that already has a value.
- Null-safe: sensor values are
nullbefore the first hardware report. Always render a placeholder for missing data. - Cross-tile reads: subscribe to
tiles.<otherId>.<key>to read another tile's published data. You can only write under your own namespace.
Touch & swipe
Tap → DOM click. A device tap is delivered as a real click event (with correct coordinates) to whatever element is under the finger. Use addEventListener("click", …), :active, <button> — all standard. There are no hover, scroll, keyboard, drag, double-tap or long-press events.
Swipe → navigation + optional config cycling. Left/right swipes move between tiles (you never see them). If you declare swipe_up / swipe_down, an up/down swipe cycles that config field through your options and pushes the new config to your page — how you build sub-tile variants without extra tiles:
// tile.json
"swipe_up": { "field": "theme", "options": ["dark", "light", "blue"] }
// index.html
desknode.onConfig(function (cfg) {
document.body.dataset.theme = cfg.theme; // re-style on each swipe
});
Network: desknode.fetch & egress
All networking is gated by the egress allow-list. Two paths share that gate:
desknode.fetch(url, options)— the recommended path. Routed through the app, which enforces egress, follows redirects only within the allow-list, and injects your declared secrets for the target host. Returns the minimal{ok, text(), json()}promise.- Plain
fetch()/XHR— also works, but only to hosts inegress, and with no secret injection. Use only for public, keyless endpoints.
// Declared: "egress": ["api.ipify.org"]
desknode.fetch("https://api.ipify.org?format=json")
.then(function (r) { return r.json(); })
.then(function (d) { console.log("my IP:", d.ip); })
.catch(function (err) { console.error("blocked or failed:", err.message); });
A request to a host not in egress rejects with "Domain <host> not in egress allowlist".
Secrets / API keys
Never hardcode keys in index.html — they leak the moment you share the tile.
- Declare the secret in
tile.json, scoped to the host that may receive it:"egress": ["api.github.com"], "secrets": { "api.github.com": ["GITHUB_TOKEN"] } - Reference it as a
{TEMPLATE}in adesknode.fetchrequest. The app substitutes the real value only when the request goes to that host:
The stringdesknode.fetch("https://api.github.com/user", { headers: { "Authorization": "Bearer {GITHUB_TOKEN}" } });{GITHUB_TOKEN}never resolves to anything in your JS — the substitution happens inside the app as the request leaves the machine.
Secrets live in the OS keychain, entered by the user out of band. Document the exact command in your tile's README so users can populate the key. A future release will add an in-app settings panel for this.
There is no secrets API for the Python worker — keep authenticated requests in JS via desknode.fetch.
worker.py — the Python escape hatch
A Python worker runs on the user's machine, so it's off unless the user explicitly opts your tile in. Prefer an HTML-only tile — desknode.fetch covers almost every data need.
When enabled, the worker runs as a separate process (crashes are isolated from the app and your page). Use it for local hardware, scraping, or heavy computation that desknode.fetch can't do.
import time
import desknode # provided by the app — no install
desknode.subscribe("system.*") # cache-only subscription
desknode.subscribe("tiles.my-tile.mode", # with change callback
lambda k, v: print("mode →", v))
while True:
price = fetch_price() # your code
desknode.publish("price", price) # → tiles.my-tile.price
temp = desknode.get("system.temp_c") # last known value or None
time.sleep(60)
Worker API
desknode.TILE_ID # str — your tile id
desknode.publish(key, value) # → tiles.<yourId>.<key>; JSON, ≤16 KB, ≤20/s
desknode.subscribe(pattern, callback=None) # glob; retained values arrive immediately
desknode.get(key, default=None) # last known value of a SUBSCRIBED key
Rules that actually bite
- Subscribe before you
get(). Worker-sideget()only sees keys you've subscribed to (unlike the JS side, which has the full seeded snapshot). - Standard library only. Third-party pip packages are not auto-installed —
requests,pandas, etc. will fail for most users. Useurllib.request,json,datetime, … - No secrets API. Authenticated calls go through JS
desknode.fetch. - Lifecycle: started when your tile becomes active (or at app start if
"background": true); sentSIGTERMwhen deactivated. A plainwhile True: … time.sleep(n)loop is fine. - Persistence: state is empty on every restart. Your working directory is the tile folder — write a file there if you need durability.
print()is safe — it goes to the app log, not the device.
Package & install
A tile is just a folder. To distribute it, zip it with tile.json at the top level of the archive:
cd my-tile
zip -r ../my-tile.zip . -x '*.DS_Store' '*/__pycache__/*'
Then in the DeskProxy app's Extensions panel:
- Click Import ZIP… and select the archive.
- Review the preview + permission summary and confirm.
- The tile is installed and added to the rotation immediately.
Publish to the Store
Once your tile is polished, you can publish it to the Tile Store — the in-app catalog users browse and install from with one tap, no cables or flashing.
1 · Build & test
Iterate in a browser, then install the zip locally and confirm it looks right in the live mirror.
2 · Version it
Set a semver version in tile.json. Bumping it is what signals an update to installed users.
3 · Submit
Send us your packaged tile. Declarative HTML/CSS tiles are eligible for open listing; worker tiles are curated at launch.
4 · Ship updates
Bump the version and resubmit. Users see an update badge in the Store and install in one tap.
Every published tile is fingerprinted; the app verifies the download before installing, so a tampered file simply won't install. Users always get exactly what was published.
Store submission is opening alongside the device launch. Join the list to be notified when developer submissions go live.
Examples
HTML-only, offline — temperature card
Reads the onboard sensor off the state bus. No network, no worker.
// tile.json
{ "id": "temp-card", "name": "Temperature", "version": "1.0.0" }
// index.html — core script
desknode.ready(function () { // ← subscribe inside ready()
desknode.subscribe("system.temp_c", function (k, v) {
document.getElementById("temp").innerHTML =
v == null ? "—" : v.toFixed(1) + "<small>°C</small>";
});
});
Authenticated network — GitHub stars
Declares one host and one secret; the token is injected only on the way out.
// tile.json
{
"id": "gh-stars", "name": "GitHub Stars", "version": "1.0.0",
"egress": ["api.github.com"],
"secrets": { "api.github.com": ["GITHUB_TOKEN"] }
}
// index.html — core script
desknode.ready(function () {
desknode.fetch("https://api.github.com/repos/owner/repo", {
headers: { "Authorization": "Bearer {GITHUB_TOKEN}", "User-Agent": "DeskProxy" }
})
.then(function (r) { return r.json(); }) // {ok, text(), json()}
.then(function (repo) {
document.getElementById("stars").textContent = "★ " + repo.stargazers_count;
})
.catch(function (err) { // rejects on any failure
document.getElementById("stars").textContent = "error: " + err.message;
});
});
Full, runnable reference tiles — a live clock and a focus timer with tap controls and swipe-configurable settings — ship inside the SDK download.
Pitfalls
- Reading state before
ready(). The cache is empty untildesknode.readyfires. Do all initialget()/subscribe()inside it. - Treating
desknode.fetchlike browserfetch. No.status/.headers; it rejects on failure. - Forgetting
egress. Any request to an undeclared host is blocked. - Hardcoding 480×320. Use
desknode.width/height. - Designing taller than the viewport. No scrolling, ever. Clipped = invisible.
- No
bodybackground. Errors or blank load → bright white screen. - Expecting request/response with the worker. There is none — publish and subscribe.
- Publishing too much. >20/s or >16 KB values are dropped. Publish on change.
- Heavy full-screen animation. Forces full-frame transfers. Animate small regions only.
Prompt template for AI-generated tiles
You are building a tile for the DeskProxy desk display using its Tile SDK.
The full SDK documentation is attached — follow it exactly.
My tile idea: <DESCRIBE YOUR IDEA>
Requirements:
- Produce exactly: tile.json, index.html, and (only if external data or local
hardware is truly needed) worker.py.
- index.html: fit the viewport with NO scrolling, use desknode.width/height,
include the dev stub, do all initial reads inside desknode.ready(), and
handle null/missing state gracefully. Dark theme, big readable type, tap-only.
- Network: declare every host in "egress". Use desknode.fetch (it REJECTS on
failure and resolves to {ok, text(), json()} — not a standard Response).
- Secrets: never hardcode keys. Declare them in "secrets" mapped to the host,
and inject with desknode.fetch(url, { headers: { Authorization: "Bearer {KEY}" }}).
- worker.py (only if needed): standard-library Python only (urllib.request,
json, …). Publish on change, sleep between polls.
Output each file in its own fenced code block labelled with its filename.