Skip to content

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:

index.htmlhtml
<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 use deska right 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, but ready() 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.

the taghtml
<script src="/__deska/sdk.js"></script>

deska.ready() and the init payload

app.jsjs
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.

initjs
{
  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'
}
FieldTypeMeaning
panel.idstringThe package id. A loaded folder gets dev- and twelve hex characters, a page on localhost dev: and its address.
panel.namestringThe package’s name from panel.json. Pass it to setTitle to show the name alone.
panel.versionstring | nullThe installed version, null while you build.
panel.devbooleanTrue 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.
permissionsstring[]What this panel may do on this computer: never more than panel.json asks for, and only what the person allowed.
localestringThe person’s locale, like es-MX, for Intl and your own words.
TipA panel can reload at any time: moved between the canvas and a dock, a tab split or reordered, a window detached, a dock folded, a project switched, an update, a restart. Keep what matters in 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.

PropertyTypeMeaning
deska.theme{ mode, tokens } | nullThe last theme Deska sent; null before ready().
deska.visiblebooleanTrue 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.idlebooleanTrue while Deska’s window does not have focus: the person is in another app.
deska.place'panel' | 'extras' | nullWhere 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:

app.jsjs
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_denied whatever its arguments.
  • Arguments must be plain data. A value postMessage cannot copy (a function, a DOM node) rejects with invalid_args without leaving the page. The file methods are the exception: files.put, files.save and clipboard.writeImage take a Blob, 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.

app.jsjs
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)

ArgumentTypeNotes
keystring1 to 256 characters.
Permission
None
Returns
The stored value, or null when the key is absent.
ErrorWhen
invalid_argsThe 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).

ArgumentTypeNotes
keystring1 to 256 characters.
valueany JSON valueStored 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
true once 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.
ErrorWhen
invalid_argsA bad key, or a value with no JSON form.
storage_fullThe write would take the package past 256 KB, or all packages on this computer past 2 MB together.
too_largeThe call carries more than 64 KB, or nests deeper than 32 levels.

deska.storage.remove(key)

ArgumentTypeNotes
keystring1 to 256 characters.
Permission
None
Returns
true, also when the key was not there.
ErrorWhen
invalid_argsA 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.

app.jsjs
// 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)

ArgumentTypeNotes
fileBlob | ArrayBuffer | typed arrayUp to 32 MB. A File from a picker, a paste or a drop is a Blob. Bytes are wrapped into one.
typestringOnly 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, else application/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.
ErrorWhen
invalid_argsThe file is not a Blob or bytes.
too_largeThe file is over 32 MB.
storage_fullThe 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.

ArgumentTypeNotes
idstringAn id from put or list.
Permission
None
Returns
The file as a Blob of its type, or null when no file has that id.
ErrorWhen
invalid_argsThe 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)

ArgumentTypeNotes
idstringAn id from put or list.
Permission
None
Returns
true, also when no file had that id.
ErrorWhen
invalid_argsThe 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.

ArgumentTypeNotes
fileBlobUp to 32 MB. Its type picks the extension.
namestringOptional, 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
true once the file is written, false when the person cancelled.
Limits
  • Only while the panel has focus.
  • One save at a time.
ErrorWhen
invalid_argsThe file is not a Blob, or the name is not a string or is over 120 characters.
too_largeThe file is over 32 MB.
not_focusedThe panel does not have focus.
declinedA save from this panel, or another of Deska’s questions, is already open.
host_errorWriting 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.

app.jsjs
// 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 paste event carries what the person pasted in clipboardData.files.
  • A drop: call preventDefault() on dragover, then read dataTransfer.files in drop.

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.

ArgumentTypeNotes
titlestringUp 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.
ErrorWhen
invalid_argsnull, an empty string, only spaces, anything that is not a string, or more than 60 characters. Deska never cuts a title.

deska.notify

app.jsjs
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.

ArgumentTypeNotes
titlestringRequired, up to 80 characters once cleaned.
bodystringOptional, 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.
ErrorWhen
permission_deniedpanel.json does not ask for notify, or the person did not allow it.
invalid_argsThe argument is not an object, the title is missing, empty or over 80, or the body is not a string or over 240.
rate_limitedThe panel sent a notice less than ten seconds ago.

deska.openUrl

app.jsjs
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.

ArgumentTypeNotes
urlstringAn absolute https address, up to 2048 characters, with no user name or password before the host.
Permission
open-url
Returns
true once the link is handed to the browser.
Limits
  • One link a second.
  • Only while the panel has focus.
ErrorWhen
permission_deniedpanel.json does not ask for open-url, or the person did not allow it.
invalid_argsNot https, not a full address, too long, or carrying a user name or password.
not_focusedThe panel does not have focus: the call came from a timer, or after the person moved on.
rate_limitedA link opened less than a second ago.
declinedThe 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.

ArgumentTypeNotes
textstringUp 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.
ErrorWhen
permission_deniedpanel.json does not ask for clipboard-write, or the person did not allow it.
invalid_argsThe text is not a string.
too_largeThe text is over 64 KB.
not_focusedThe panel does not have focus.
host_errorThe system clipboard refused the write.
app.jsjs
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.

ArgumentTypeNotes
imageBlob | ArrayBuffer | typed arrayA 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.
ErrorWhen
permission_deniedpanel.json does not ask for clipboard-write, or the person did not allow it.
invalid_argsNot a Blob, not a PNG or JPEG, or an image that does not decode.
too_largeThe image is over 32 MB.
not_focusedThe panel does not have focus.

deska.extras.set

app.jsjs
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 rest

deska.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.

ArgumentTypeNotes
statestring | nullThe row’s state line, up to 60 characters once cleaned.
progressnumber | null0 to 1 draws a ring around the icon.
untilnumber | nullA time in milliseconds since 1970. Deska shows the time left and keeps counting with no frame alive.
control'play' | 'pause' | 'start' | 'stop' | nullThe 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.
ErrorWhen
permission_deniedpanel.json has no contributes.extras.
invalid_argsNot 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.

app.jsjs
// 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.

ArgumentTypeNotes
elementElementThe slot in your page the embed covers. Give it a size.
urlstringAn 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, and remove(), which hides it and stops following the element.
Limits
  • Four embeds at a time per panel page.
ErrorWhen
permission_deniedThe host is not in embeds, or the address is plain http or on another port.
invalid_argsThe 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().

ArgumentTypeNotes
idstringYour name for the embed: 1 to 40 of A-Za-z0-9_-.
urlstringAn 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.
ErrorWhen
permission_deniedThe host is not in embeds, or the address is plain http or on another port.
invalid_argsA 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.

ArgumentTypeNotes
idstringAn 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.

ArgumentTypeNotes
idstring1 to 40 of A-Za-z0-9_-.
Permission
None
Returns
true, also when no embed had that id.
ErrorWhen
invalid_argsThe id is not 1 to 40 of A-Za-z0-9_-.

Events

app.jsjs
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
EventPayloadWhen
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.

js
// 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.