← Back to site
Tile SDK · Developer guide

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:

my-tile/ ├── tile.json # manifest — REQUIRED ├── index.html # the UI — REQUIRED (HTML/CSS/JS; one file is fine) └── worker.py # OPTIONAL Python backend (off by default)

The mental model

worker.py ──publish──▶ STATE BUS ──push──▶ index.html ──▶ your desk (Python, (key/value, (rendered on your optional, off retained) computer, streamed by default) to the device)
  • Your page renders on your computer, not on the device. The DeskProxy app renders index.html in 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 click events. <button>, addEventListener, :active — all standard. Swipes never reach your page; the system uses them for navigation.
Building with an AI model?

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.

No hardware needed

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

FileRequiredPurpose
tile.jsonYesManifest: identity, permissions, config, swipe behaviour.
index.htmlYesThe UI. Loads in a sandboxed browser engine with window.desknode injected. May reference sibling files (style.css, app.js, images) via relative paths.
worker.pyOptional Python backend. Disabled by default; the user must opt in.
anything elseAssets, 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.

ConstraintRule
Viewportdesknode.width × desknode.height480×320 today. Read the constants; don't hardcode — a taller display is coming.
ScrollingImpossible. The runtime forces overflow: hidden and pins the page to the viewport. Anything past the edge is clipped and invisible — design to fit.
InputSingle taps only, delivered as a DOM click. No hover, scroll, keyboard, drag, pinch, double-tap or long-press.
SwipesConsumed by the system for tile navigation. A swipe up/down can cycle your config if you declare it (see Touch & swipe).
Frame rateThe 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 targetsA fingertip on a 4″ screen — make tappable elements ≥ 48 px tall.
ReadabilityRead at arm's length. Big type, high contrast, dark backgrounds.
BackgroundAlways 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"] }
}
FieldDefaultMeaning
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.
backgroundfalsetrue → your worker (if enabled) runs even while your tile is off-screen. false → only while your tile is active.
refresh_rate0.0Minimum 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_downnullA 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, descriptionInformational. 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 egress hosts, 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.py is 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

CapabilityDeclared inEnforcement
Network egressegressEvery 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 keyssecretsStored 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 codepresence of worker.pyRuns only if the user explicitly opts your tile in. The HTML page itself has no filesystem access.
The safe default

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().
desknode.fetch is not a browser fetch

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.

PatternWriterAvailable now
system.*the appsystem.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 null before 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:

  1. 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.
  2. Plain fetch() / XHR — also works, but only to hosts in egress, 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.

  1. Declare the secret in tile.json, scoped to the host that may receive it:
    "egress":  ["api.github.com"],
    "secrets": { "api.github.com": ["GITHUB_TOKEN"] }
  2. Reference it as a {TEMPLATE} in a desknode.fetch request. The app substitutes the real value only when the request goes to that host:
    desknode.fetch("https://api.github.com/user", {
      headers: { "Authorization": "Bearer {GITHUB_TOKEN}" }
    });
    The string {GITHUB_TOKEN} never resolves to anything in your JS — the substitution happens inside the app as the request leaves the machine.
Provisioning the value

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 worker runs host code — disabled by default

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-side get() 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. Use urllib.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); sent SIGTERM when deactivated. A plain while 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:

  1. Click Import ZIP… and select the archive.
  2. Review the preview + permission summary and confirm.
  3. 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.

Integrity built in

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 until desknode.ready fires. Do all initial get()/subscribe() inside it.
  • Treating desknode.fetch like browser fetch. 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 body background. 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.
Questions or want to publish a tile? Join the developer list. · deskproxy.com