Build panels
SDK reference
Every method of window.deska: its arguments, the permission it needs, what it answers, how it fails and its limits. Storage and files, the clipboard, notices and links, pages from other sites, plus the init payload, the live properties, the events and the messages underneath.
Loading the SDK
Deska serves the SDK inside every panel at /__deska/sdk.js. It is not a file in your folder, and it always matches the Deska that runs the panel, so there is nothing to install or update. Load it as a classic script, before any script that uses window.deska:
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="app.css">
<!-- A classic script, before anything that uses window.deska -->
<script src="/__deska/sdk.js"></script>
<script type="module" src="app.js"></script>
</head>- A
type="module"script runs after the classic ones, so a module can usedeskaright away. - The SDK says it is ready as soon as it loads, and repeats it every quarter second until Deska answers, so it does not matter which side starts first.
- Opened outside Deska (straight in a browser), the page still gets a
deska, butready()never resolves and calls never answer. Use the preview tool to run a panel outside Deska; see Testing and debugging.
The SDK is a public contract that only grows: a method that exists in version 1 keeps working the same way. sdk in panel.json says which version you wrote for.
<script src="/__deska/sdk.js"></script>deska.ready() and the init payload
const init = await deska.ready()
document.documentElement.lang = init.locale
if (init.place === 'extras') document.body.classList.add('compact')
render(await deska.storage.get('state'))deska.ready() answers once Deska has met the page, with everything the panel needs to draw its first frame. It is the same promise every time you call it. Calls made before it resolves wait and are sent the moment it does.
{
panel: { id: 'pomodoro-plus', name: 'Pomodoro Plus', version: '1.2.0', dev: false },
place: 'panel',
theme: { mode: 'dark', tokens: { '--deska-bg': 'rgb(r, g, b)', /* and 15 more */ } },
visibility: { visible: true, idle: false },
permissions: ['notify'],
locale: 'en-US'
}| Field | Type | Meaning |
|---|---|---|
panel.id | string | The package id. A loaded folder gets dev- and twelve hex characters, a page on localhost dev: and its address. |
panel.name | string | The package’s name from panel.json. Pass it to setTitle to show the name alone. |
panel.version | string | null | The installed version, null while you build. |
panel.dev | boolean | True for a loaded folder or a page on localhost. |
place | 'panel' | 'extras' | Where this page is drawn: on the desk, or inside the Extras popover. |
theme | { mode, tokens } | mode is dark or light; tokens maps each --deska-* name to a resolved value. See Theme tokens. |
visibility | { visible, idle } | See Properties. |
permissions | string[] | What this panel may do on this computer: never more than panel.json asks for, and only what the person allowed. |
locale | string | The person’s locale, like es-MX, for Intl and your own words. |
deska.storage, images and other files in deska.files, and rebuild from init, never from memory.Properties
Live fields on deska, filled in by ready() and kept current after it. Read them any time; listen to the events below to hear when they change.
| Property | Type | Meaning |
|---|---|---|
deska.theme | { mode, tokens } | null | The last theme Deska sent; null before ready(). |
deska.visible | boolean | True while the panel is on screen: its project and worktree are the ones shown, it is in view, and the zoomed out far view is not covering it with a card. |
deska.idle | boolean | True while Deska’s window does not have focus: the person is in another app. |
deska.place | 'panel' | 'extras' | null | Where this page is drawn; null before ready(). |
A hidden panel keeps running. visible: false is your cue to stop timers, animations and polling, and catch up when it shows again; audio may keep playing.
How calls answer
Every method returns a promise. It resolves with the answer, and rejects with an Error whose code (and message) is one of ten codes. Answer the ones a person can act on and log the rest:
try {
await deska.clipboard.write(text)
say('Copied')
} catch (err) {
// err is an Error whose code says why
if (err.code === 'not_focused') say('Click the panel, then copy again')
else if (err.code === 'too_large') say('Too long to copy')
else console.warn('copy failed:', err.code)
}- Permissions are checked first: a call without its permission rejects with
permission_deniedwhatever its arguments. - Arguments must be plain data. A value
postMessagecannot copy (a function, a DOM node) rejects withinvalid_argswithout leaving the page. The file methods are the exception:files.put,files.saveandclipboard.writeImagetake aBlob, held to the 32 MB file limit instead of the 64 KB one. - Every call counts against the frame’s budget of 20 a second. See Error codes and limits for every code and limit.
deska.storage
Where a panel’s data lasts. The page runs with an opaque origin, so localStorage, indexedDB and cookies throw or forget. Values are JSON, kept on this computer, per package: every copy of the panel, on any desk and in Extras, reads and writes the same keys. For images and anything large, use deska.files and keep only the file’s id here.
const saved = await deska.storage.get('places') // null on a first run
const places = saved ?? ['Europe/Berlin']
places.push('Asia/Tokyo')
try {
await deska.storage.set('places', places)
} catch (err) {
if (err.code === 'storage_full') showFullNotice()
}
await deska.storage.keys() // ['places']
await deska.storage.remove('places')deska.storage.get(key)
| Argument | Type | Notes |
|---|---|---|
key | string | 1 to 256 characters. |
- Permission
- None
- Returns
- The stored value, or
nullwhen the key is absent.
| Error | When |
|---|---|
invalid_args | The key is not a string, is empty or is longer than 256 characters. |
deska.storage.set(key, value)
Two copies of the panel writing the same key settle on the last write: storage has no locks. Read, change and write in one step, and re-read when a copy is shown again (Habit Streaks does both).
| Argument | Type | Notes |
|---|---|---|
key | string | 1 to 256 characters. |
value | any JSON value | Stored as JSON. undefined, a function, a BigInt or a value that contains itself is refused; NaN and Infinity become null, as in JSON.stringify. |
- Permission
- None
- Returns
trueonce the value is in the panel’s storage. It reaches disk within half a second, and on unload.- Limits
- 256 KB per package: the size of all its keys and values as JSON, in UTF-8.
- 2 MB for every community panel on the computer together.
- A write that does not grow the stored data always passes, so a package over the limit can shrink back under it.
- One call carries at most 64 KB, so a bigger value is stored in parts under several keys.
| Error | When |
|---|---|
invalid_args | A bad key, or a value with no JSON form. |
storage_full | The write would take the package past 256 KB, or all packages on this computer past 2 MB together. |
too_large | The call carries more than 64 KB, or nests deeper than 32 levels. |
deska.storage.remove(key)
| Argument | Type | Notes |
|---|---|---|
key | string | 1 to 256 characters. |
- Permission
- None
- Returns
true, also when the key was not there.
| Error | When |
|---|---|
invalid_args | A bad key. |
deska.storage.keys()
- Permission
- None
- Returns
- Every key the package holds, as an array of strings.
Storage belongs to the computer and the package. It does not follow the account to another computer, and removing the panel from Deska deletes it. A loaded folder keeps its own storage, apart from the installed version of the same panel.
deska.files
Files the panel keeps on this computer: an image someone pasted, a picture it drew, an export. Like storage, files belong to the package: every copy of the panel, on any desk and in Extras, sees the same ones. Keep each file’s id in deska.storage to find it again. No permission is needed.
// Keep an image, and only its id in storage
try {
const kept = await deska.files.put(file) // { id, type, size, createdAt }
const ids = (await deska.storage.get('shots')) ?? []
await deska.storage.set('shots', [...ids, kept.id])
} catch (err) {
if (err.code === 'too_large') say('Pick an image under 32 MB')
if (err.code === 'storage_full') say('Remove a few images to make room')
}
// Show it again after a reload
const blob = await deska.files.get(id) // null once removed
if (blob) img.src = URL.createObjectURL(blob)
// Save a copy where the person chooses, from the click
saveButton.addEventListener('click', async () => {
const saved = await deska.files.save(blob, 'screenshot.png')
if (saved) say('Saved') // false: they cancelled
})deska.files.put(file)
| Argument | Type | Notes |
|---|---|---|
file | Blob | ArrayBuffer | typed array | Up to 32 MB. A File from a picker, a paste or a drop is a Blob. Bytes are wrapped into one. |
type | string | Only with bytes: the MIME type, like image/png. A Blob carries its own. |
- Permission
- None
- Returns
{ id, type, size, createdAt }: the id to keep, the MIME type (the file’s own when it reads as one, elseapplication/octet-stream), the size in bytes and when it was kept, in milliseconds since 1970.- Limits
- 32 MB per file.
- 256 MB and 2,000 files per package.
- 1 GB for every community panel on the computer together.
| Error | When |
|---|---|
invalid_args | The file is not a Blob or bytes. |
too_large | The file is over 32 MB. |
storage_full | The file would take the package past 256 MB or 2,000 files, or every package on this computer past 1 GB together. |
deska.files.get(id)
Show an image with URL.createObjectURL(blob); the panel’s policy allows blob: images and media. Call URL.revokeObjectURL once the image is gone from the page.
| Argument | Type | Notes |
|---|---|---|
id | string | An id from put or list. |
- Permission
- None
- Returns
- The file as a
Blobof its type, ornullwhen no file has that id.
| Error | When |
|---|---|
invalid_args | The id is not one Deska could have given. |
deska.files.list()
- Permission
- None
- Returns
- Every file the package keeps, as an array of
{ id, type, size, createdAt }.
deska.files.remove(id)
| Argument | Type | Notes |
|---|---|---|
id | string | An id from put or list. |
- Permission
- None
- Returns
true, also when no file had that id.
| Error | When |
|---|---|
invalid_args | The id is not one Deska could have given. |
deska.files.save(file, name)
Deska shows the system’s save dialog, titled with the panel’s name, and the person picks the folder and the name. The dialog is their consent, so no permission is needed, and the page never learns where the file went. It is the way a panel saves a file: downloads, <a download> included, are refused in the sandbox.
| Argument | Type | Notes |
|---|---|---|
file | Blob | Up to 32 MB. Its type picks the extension. |
name | string | Optional, up to 120 characters: the name to suggest. Deska makes it safe for the system and adds the extension the type calls for. |
- Permission
- None
- Returns
trueonce the file is written,falsewhen the person cancelled.- Limits
- Only while the panel has focus.
- One save at a time.
| Error | When |
|---|---|
invalid_args | The file is not a Blob, or the name is not a string or is over 120 characters. |
too_large | The file is over 32 MB. |
not_focused | The panel does not have focus. |
declined | A save from this panel, or another of Deska’s questions, is already open. |
host_error | Writing the file failed. |
Files belong to the computer and the package, like storage. They do not follow the account to another computer, an update keeps them, and removing the panel from Deska deletes them. A loaded folder keeps its own files, apart from the installed version of the same panel.
Taking images in
Bringing an image into a panel needs no method: the page is a web page. A file picker, a paste and a drop all hand it File objects, which deska.files.put takes as they are.
// A picker: <input type="file" accept="image/*" multiple>
picker.addEventListener('change', () => keep(picker.files))
// A paste while the panel has focus
document.addEventListener('paste', (event) => keep(event.clipboardData.files))
// A drop on the panel
document.addEventListener('dragover', (event) => event.preventDefault())
document.addEventListener('drop', (event) => {
event.preventDefault()
keep(event.dataTransfer.files)
})
async function keep(list) {
for (const file of list) {
if (file.type.startsWith('image/')) await deska.files.put(file)
}
}- A picker:
<input type="file">opens the system’s file dialog. - A paste: the page’s own
pasteevent carries what the person pasted inclipboardData.files. - A drop: call
preventDefault()ondragover, then readdataTransfer.filesindrop.
Each of these starts with the person. Reading the clipboard on its own (navigator.clipboard.read) stays refused, so no panel sees what was copied until someone pastes it there.
deska.setTitle
deska.setTitle(title)
A short line under the panel’s name on the canvas, shown as untrusted text after the package’s name: Pomodoro Plus: Focus 12:31. There is no way to clear it; set the panel’s own name, init.panel.name, to show the name alone.
Everywhere else (Dex, the Command Palette, the panel index, a phone or another computer looking at the desk) the panel goes by its package’s name, or the name the person gave it, never by its title.
| Argument | Type | Notes |
|---|---|---|
title | string | Up to 60 characters once invisible characters are taken out and runs of spaces folded into one. |
- Permission
- None
- Returns
true- Limits
- Written at most four times a second; the last title asked for wins.
- Shown on the canvas only, not in a panel or dock window of its own.
| Error | When |
|---|---|
invalid_args | null, an empty string, only spaces, anything that is not a string, or more than 60 characters. Deska never cuts a title. |
deska.notify
await deska.notify({ title: 'Break time', body: 'Twenty five minutes done.' })
// The bell shows "Pomodoro Plus: Break time"deska.notify({ title, body })
A notice in Deska’s bell, labelled with the panel’s name so it can never pass for one of Deska’s. From a panel or dock window of its own it reaches the main window’s bell. It works whether or not the panel has focus: it is how a panel asks for attention.
| Argument | Type | Notes |
|---|---|---|
title | string | Required, up to 80 characters once cleaned. |
body | string | Optional, up to 240 characters. Leave it out or pass null for none. |
- Permission
notify- Returns
true- Limits
- One notice every 10 seconds per panel. Bundle several changes into one notice.
| Error | When |
|---|---|
permission_denied | panel.json does not ask for notify, or the person did not allow it. |
invalid_args | The argument is not an object, the title is missing, empty or over 80, or the body is not a string or over 240. |
rate_limited | The panel sent a notice less than ten seconds ago. |
deska.openUrl
link.addEventListener('click', async (event) => {
event.preventDefault()
try {
await deska.openUrl(link.href)
} catch (err) {
if (err.code === 'not_focused') return // the click did not reach the panel
if (err.code === 'declined') return // the person said no; do not ask again
}
})deska.openUrl(url)
Opens the link in the person’s browser. The first time the panel opens a given host, Deska asks (“Open example.com?”), naming the panel and showing the link; its Open button takes no click for half a second, so a button under the cursor cannot answer for the person. After an Open, links to that host open without asking. After a Cancel, Deska asks nothing more until the page starts over.
The link does not need to be in network: that list is what the panel reaches itself, not what it may show the person.
| Argument | Type | Notes |
|---|---|---|
url | string | An absolute https address, up to 2048 characters, with no user name or password before the host. |
- Permission
open-url- Returns
trueonce the link is handed to the browser.- Limits
- One link a second.
- Only while the panel has focus.
| Error | When |
|---|---|
permission_denied | panel.json does not ask for open-url, or the person did not allow it. |
invalid_args | Not https, not a full address, too long, or carrying a user name or password. |
not_focused | The panel does not have focus: the call came from a timer, or after the person moved on. |
rate_limited | A link opened less than a second ago. |
declined | The person pressed Cancel, another of Deska’s questions was open, or this page was already refused once. |
deska.clipboard
deska.clipboard.write(text)
Puts text on the clipboard. It is the only way a panel copies: navigator.clipboard is refused in the sandbox, and without this permission the page’s own document.execCommand('copy') does nothing. Copying a selection with the keyboard works in every panel, as on any page. No panel can read the clipboard.
| Argument | Type | Notes |
|---|---|---|
text | string | Up to 64 KB in UTF-8. |
- Permission
clipboard-write- Returns
true- Limits
- Only while the panel has focus, so call it from the click or key press itself.
| Error | When |
|---|---|
permission_denied | panel.json does not ask for clipboard-write, or the person did not allow it. |
invalid_args | The text is not a string. |
too_large | The text is over 64 KB. |
not_focused | The panel does not have focus. |
host_error | The system clipboard refused the write. |
copyButton.addEventListener('click', async () => {
const blob = await deska.files.get(id) // a PNG or JPEG
try {
await deska.clipboard.writeImage(blob)
say('Image copied')
} catch (err) {
if (err.code === 'invalid_args') say('Only PNG and JPEG images can be copied')
else if (err.code === 'not_focused') say('Click the panel, then copy again')
}
})deska.clipboard.writeImage(image)
Puts an image on the clipboard, ready to paste in any app. It needs the same permission as copying text. Draw on a canvas and copy what canvas.toBlob() gives, or copy a file the panel keeps.
| Argument | Type | Notes |
|---|---|---|
image | Blob | ArrayBuffer | typed array | A PNG or JPEG, up to 32 MB. Bytes are wrapped as image/png. |
- Permission
clipboard-write- Returns
true- Limits
- Only while the panel has focus.
- PNG and JPEG only.
| Error | When |
|---|---|
permission_denied | panel.json does not ask for clipboard-write, or the person did not allow it. |
invalid_args | Not a Blob, not a PNG or JPEG, or an image that does not decode. |
too_large | The image is over 32 MB. |
not_focused | The panel does not have focus. |
deska.extras.set
await deska.extras.set({
state: 'Focus, 12 min left',
progress: 0.52,
until: Date.now() + 12 * 60_000,
control: 'pause',
})
await deska.extras.set({ progress: null, until: null }) // clear two fields, keep the restdeska.extras.set({ state, progress, until, control })
Fills the package’s row in Extras. What you set stays after every frame of the package closes. See Extras for the whole place.
| Argument | Type | Notes |
|---|---|---|
state | string | null | The row’s state line, up to 60 characters once cleaned. |
progress | number | null | 0 to 1 draws a ring around the icon. |
until | number | null | A time in milliseconds since 1970. Deska shows the time left and keeps counting with no frame alive. |
control | 'play' | 'pause' | 'start' | 'stop' | null | The row’s round button. A press sends extras.control. |
- Permission
- None, but panel.json must declare
contributes.extras - Returns
true- Limits
- Fields you leave out keep their value; null clears one.
| Error | When |
|---|---|
permission_denied | panel.json has no contributes.extras. |
invalid_args | Not an object, a key other than the four, a state over 60, a progress outside 0 to 1, a time that is not a valid date, or an unknown control. |
deska.embed
Shows another site’s page inside the panel, like a video player, a map or a public board. The panel never frames it itself: Deska draws a frame of its own over the panel, where the page asks, signed out and kept apart from the panel. The page’s host must be in embeds, which needs "sdk": 2; see Pages from other sites and what the frame allows.
// panel.json declares "embeds": ["www.youtube-nocookie.com"] and "sdk": 2
// index.html holds <div id="player" style="aspect-ratio: 16 / 9"></div>
const slot = document.getElementById('player')
let player = null
async function play(videoId) {
if (player) await player.remove()
try {
player = await deska.embed.attach(slot, `https://www.youtube-nocookie.com/embed/${videoId}`)
} catch (err) {
if (err.code === 'permission_denied') say('This panel cannot show that site')
else if (err.code === 'invalid_args') say('Close a player first')
}
}
play('dQw4w9WgXcQ')deska.embed.attach(element, url)
The easy way. It shows the page over the element and follows it: when the element resizes, the page scrolls or the window resizes, the SDK reads the element’s box again, at most once a frame, and moves the embed only when the box changed. An element hidden to 0 by 0 hides the embed without removing it.
| Argument | Type | Notes |
|---|---|---|
element | Element | The slot in your page the embed covers. Give it a size. |
url | string | An https address on a host in embeds, on the default port, with no user name. |
- Permission
- None, but the URL’s host must be in
embeds - Returns
Promise<{ id, remove() }>: the embed’s id, andremove(), which hides it and stops following the element.- Limits
- Four embeds at a time per panel page.
| Error | When |
|---|---|
permission_denied | The host is not in embeds, or the address is plain http or on another port. |
invalid_args | The element is not an element, or the panel already shows four embeds. |
deska.embed.show(id, url, rect)
The same by hand. Showing an id again points it at the new address with a new frame. Embeds go with the page: a reload or a closed panel takes them down, so show them again after ready().
| Argument | Type | Notes |
|---|---|---|
id | string | Your name for the embed: 1 to 40 of A-Za-z0-9_-. |
url | string | An https address on a host in embeds, on the default port, with no user name. |
rect | { x, y, width, height } | Where to draw it, in CSS pixels of the page. Sides from 0 to 8000, offsets within 8000 either way. |
- Permission
- None, but the URL’s host must be in
embeds - Returns
true- Limits
- Four embeds at a time per panel page.
| Error | When |
|---|---|
permission_denied | The host is not in embeds, or the address is plain http or on another port. |
invalid_args | A bad id or rect, or a fifth embed while four are shown. |
deska.embed.move(id, rect)
Send it whenever the page moves the slot of an embed you showed by hand. attach sends it for you.
| Argument | Type | Notes |
|---|---|---|
id | string | An id the page shows. |
rect | { x, y, width, height } | The new place, in CSS pixels of the page. |
- Permission
- None
- Returns
- Nothing. A move has no answer.
- Limits
- Not counted against the 20 calls a second, so it can follow scrolling; it still counts toward the 400 messages a second that take a frame down.
- A move for an id the page does not show, or with a bad rect, is dropped.
deska.embed.hide(id)
Takes the embed down and frees its place among the four.
| Argument | Type | Notes |
|---|---|---|
id | string | 1 to 40 of A-Za-z0-9_-. |
- Permission
- None
- Returns
true, also when no embed had that id.
| Error | When |
|---|---|
invalid_args | The id is not 1 to 40 of A-Za-z0-9_-. |
Events
const stop = deska.on('visibility', ({ visible, idle }) => {
if (visible && !idle) startTicking()
else stopTicking()
})
deska.on('theme', ({ mode }) => redrawChart(mode))
deska.on('extras.control', ({ control }) => {
if (control === 'play') play()
if (control === 'pause') pause()
})
stop() // stop listening| Event | Payload | When |
|---|---|---|
theme | { mode, tokens } | The theme or the accent changed. The SDK has already written the new tokens on the page, so CSS that uses var(--deska-*) needs no listener; listen to redraw a canvas or a chart. |
visibility | { visible, idle } | The panel came into view or left it, or Deska’s window gained or lost focus. |
extras.control | { control } | The person pressed the Extras row’s button. Every open frame of the package hears it. |
deska.on(name, fn)returns a function that stops listening. Passing something that is not a function does nothing.- Events arrive only after
ready(). A listener that throws is logged in the console and does not stop the others.
The messages underneath
The SDK is a thin layer over postMessage between the frame and Deska. You never need it, but it explains what you see in a debugger. Deska only reads messages from the frame’s own window, and only answers the page that finished the handshake; replies go to any origin because the frame’s is opaque, and none carries anything private.
// Frame to Deska
{ type: 'deska:ready', sdk: 1 }
{ type: 'deska:call', id: 'c1', method: 'storage.get', args: ['zone'] }
// Deska to the frame
{ type: 'deska:init', payload: { panel, place, theme, visibility, permissions, locale } }
{ type: 'deska:result', id: 'c1', ok: true, value: 'Europe/Berlin' }
{ type: 'deska:result', id: 'c2', ok: false, error: 'permission_denied' }
{ type: 'deska:theme', payload: { mode, tokens } }
{ type: 'deska:visibility', payload: { visible, idle } }
{ type: 'deska:event', name: 'extras.control', payload: { control: 'play' } }A call before deska:ready answers not_ready. Call ids are up to 64 characters; a message without a usable id is dropped unanswered.
deska.embed.move is not a call but a message of its own, { type: 'deska:embed-place', id, rect }, which gets no answer and is left out of the call limit.