Plugins

Writing a Slate plugin

Everything a plugin can do, and everything it cannot. No prior knowledge of the codebase assumed. When you are ready to share one, read Publishing.

The short version A plugin is a manifest.json and some JavaScript. It runs in a Web Worker with no DOM, no network and no access to your notes — except exactly what the manifest asks for and the user grants. You never write HTML or CSS; you return blocks and the app draws them in the current theme.

1What a plugin is

A worker script and a manifest describing it. The manifest declares one or more kinds — the shapes a plugin can take in the app:

KindWhat it isEntry point key
commandEntries in the ⌘P palettecommand
windowA view, floating or docked in a panewindow
status-itemA segment in the statuslinestatusItem
serviceBackground work with no UI of its ownservice
panelA docked side panelpanel

One plugin may be several at once. The Timer is a window and a status-item: a face you open, and a countdown you can see without opening anything.

2Your first plugin

The fastest start is not an empty folder. Open Slate and run Fork a built-in plugin from ⌘P — it copies a working plugin into your vault, already loading, and you edit from there. Timer is a good one to fork; Weather is the one to read if you want to see a network permission used properly.

Starting from nothing, this is the whole of it:

manifest.json

{
  "schemaVersion": 1,
  "apiVersion": "slate.v1",
  "id": "alice.wordcount",
  "name": "Word count",
  "version": "1.0.0",
  "author": "alice",
  "description": "Counts the words in every note in a folder.",
  "category": "knowledge",
  "maturity": "stable",
  "kinds": ["window"],
  "entryPoints": { "window": "window.js" },
  "permissions": ["vault:read:notes/**"],
  "screenshots": ["https://example.com/wordcount.png"]
}

window.js

slate.registerWindow({
  id: 'main',
  title: 'Word count',

  async render() {
    const paths = await slate.vault.list('notes/**')
    const rows = []

    for (const path of paths.slice(0, 20)) {
      const text = await slate.vault.read(path)
      rows.push({ t: 'kv', k: path, v: String(text.split(/\s+/).length) })
    }

    return [
      { t: 'section', title: 'Longest notes' },
      ...rows,
    ]
  },
})

Open it with ⌘⇧P. Edit the file, then run Reload plugins to pick up the change. console.log output appears in the plugin's log in the same palette, which is why authoring never has to leave the app.

3The structure

What you write is a folder:

my-plugin/
  manifest.json      ← required, at the root
  window.js          ← whatever entryPoints names
  helper.js          ← other files you import
  README.md          ← expected by review
  LICENSE            ← expected by review

What the app reads is one file per plugin, <vault>/.slate/plugins/<id>.plugin.json, holding that folder as a bundle:

{
  "manifest": { … },
  "files": [
    { "path": "manifest.json", "text": "…" },
    { "path": "window.js",     "text": "…" }
  ],
  "source": { "origin": "url", "version": "1.0.0", "installedAt": "…" }
}
Dropping a folder into .slate/plugins/<id>/ does not work Slate's storage layer lists files and not directories, so a folder you drop in is invisible to the app. It is a known gap, not a decision. Use Fork a built-in plugin or Install a plugin from a URL…, both of which write the bundle for you.

You will rarely write that JSON by hand. To test a plugin you wrote in a folder, publish the bundle anywhere over HTTPS and use Install a plugin from a URL… — the same door the marketplace uses, so if it installs that way it will install for everyone else too.

4The manifest, field by field

Required

FieldRules
schemaVersionExactly 1.
apiVersionExactly "slate.v1".
idLowercase, dotted, at least one dot: alice.wordcount. Letters, digits and hyphens per segment — no underscores. Max 80 characters. slate.* is reserved. To publish, one segment must be your publisher handle.
name2–60 characters. What a person calls it.
versionSemver: 1.0.0. Published versions are immutable, so this must go up every time.
authorYour name or handle.
description10–200 characters, one sentence, says what it does.
kindsA non-empty array from the table in §1.
entryPointsOne file per kind, keyed as in §1. The file must exist in the bundle.
permissionsAn array — [] if you need nothing.

Optional

FieldRules
categoryOne of capture, knowledge, tasks, dev, view, sync, fun. Required to publish.
maturitystable (default), experimental, or example. The last two are badged in the browser.
licenseSPDX id. Expected by review.
screenshotsUp to 4 https URLs of images, shown in the marketplace. The first is the thumbnail on the list row, and the rest become a slideshow. Host them wherever you like — the bundle carries text, so it cannot hold a PNG.
settingsVault path to a note whose frontmatter is your config — see §8.
window{ displayName, defaultPresentation: "float" | "pane", defaultRect, minRect }
statusItem{ displayName, section: "left" | "right" }

5Permissions

The part worth reading twice. This list is shown to a person, in plain words, before they enable your plugin — and the shortest path to a plugin nobody installs is asking for more than you need.

PermissionGrantsShown to the user as
vault:read:<glob>Read notes matching the glob“Read notes matching <glob>
vault:write:<glob>Create and change notes matching the glob“Create and change notes matching <glob>
network:<host>fetch to exactly that hostname“Send and receive data from <host>
secret:<name>Use a stored credential without seeing it“Use your saved <name> credential, without ever seeing it”
device:<scope>A paired phone. Scopes: status, pairing, clipboard, share, sms, commandsone sentence per scope
terminalAn interactive shell as your user“Open an interactive shell with your user account”

Rules that are enforced, not advised

  • A bare vault:read or vault:write means ** — the whole vault. Say what you actually need: vault:read:journal/** is treated as low risk where vault:read is not.
  • network:** and secret:** are rejected. Name the host. Matching is exact — network:open-meteo.com does not grant api.open-meteo.com, and a plugin that needs the subdomain must name the subdomain.
  • https only, except http to localhost and 127.0.0.1, because a local model has no certificate to offer.
  • Nothing reaches .slate/. Not with vault:write:**, not with any glob. The single exception is your own settings: note. This is enforced in the permission broker, not the manifest, so it cannot be negotiated with.
  • A permission your code never uses is reported back to you at publish. Drop it.
How to think about scope Every permission you drop is one fewer reason for someone to close the prompt. A plugin that writes to boards/** installs; the same plugin asking for vault:write makes a person stop and think, and thinking is where installs go to die.

6The slate API

One global. Everything your plugin can do goes through it.

Always available

slate.version                    // 'slate.v1'
slate.id                         // your plugin id

slate.registerCommand({ id, title, category?, binding?, keywords?, hidden?, run })
slate.registerWindow({ id, title, render, onKey? })
slate.registerStatusItem({ id, render })

slate.refresh(viewId)            // ask the host to redraw one of your views
slate.on(event, handler)         // returns an unsubscribe function

slate.ui.notify(text, level?)    // 'info' | 'warning' | 'error'
slate.ui.sound(name)             // a name, never a URL
slate.ui.open(path)              // open a note in the editor
slate.ui.prompt({ title, initial?, placeholder?, confirmLabel? })
                                 // one line of text, or null if cancelled

slate.settings.get()             // frontmatter of your `settings:` note
slate.settings.reload()

Every register* call returns a function that unregisters it.

Only with the matching permission

// vault:read
slate.vault.list(glob?)          // paths you are allowed to see
slate.vault.read(path)

// vault:write
slate.vault.write(path, content)
slate.vault.append(path, line)

// network:<host>
fetch(url)                       // to your declared hosts only

// device:<scope>
slate.device.list() / .status() / .share(id, what) / .sms.* / …

// terminal
slate.terminal.*
These are absent, not denied A plugin with no network: grant does not get a fetch that throws. It gets no fetch at all — along with no XMLHttpRequest, WebSocket, indexedDB, localStorage, importScripts or Worker. They are deleted from the worker before your first line runs. Feature-detect if you must, but the manifest is the real answer.

There is no input surface of your own. slate.ui.prompt is the app's one text input and plugins borrow it. This is deliberate: a plugin that could draw its own password-shaped field could phish for one.

7Rendering: blocks

render() returns an array of blocks. You never write HTML or CSS. The host draws blocks with the current theme, so your plugin looks right in all six palettes and inherits the app's focus and contrast behaviour without you doing anything.

text  row  section  kv  meter  ring  bars  donut  grid
tabs  chips  kbd  button  group  divider  spacer  empty  sky  terminal
return [
  { t: 'section', title: 'This week' },
  { t: 'kv', k: 'Notes written', v: '14' },
  { t: 'meter', label: 'Inbox', value: 3, max: 10 },
  { t: 'divider' },
  { t: 'button', label: 'Open inbox', action: { command: 'open-inbox' } },
]

Tones are default, muted, subtle, accent, success, warning, danger — never a hex colour. A block with an unrecognised tone renders in the default one rather than failing.

onKey(key) receives keystrokes while your window has focus. Return true if you handled one, and anything else to let it fall through to the app.

8Settings

A plugin's configuration is frontmatter in a note it declares:

"settings": ".slate/plugins/wordcount.md"

Declaring one earns your plugin a “<name>: settings” command that opens that note. Read it with slate.settings.get(). Slate reads it once, as your plugin starts, so saving the note offers a restart in a notice rather than performing one.

This is also the only path under .slate/ your plugin may touch, and only its own. It is where an API key would live, which is why no other plugin can read it.

Screenshots

A row in the marketplace says what your plugin does; a picture says what it looks like, which for anything that draws a window is most of the question. Declare up to four:

"screenshots": [
  "https://example.com/standup-window.png",
  "https://example.com/standup-docked.png"
]

They are https URLs, not files — the publish bundle carries text and has no way to hold a PNG. The first becomes the thumbnail on the list row; the rest become a slideshow on the detail sheet. Shoot them in a real vault at a readable size, and show the plugin doing its job rather than its empty state.

They are decoration, and are treated as such Because a URL can change after review without the version changing, a screenshot is never evidence of anything. The permission list beside it is what a person is actually deciding on, and that is immutable.

9Rules the runtime enforces

  1. A render must not block. The watchdog terminates a plugin that stalls the host for about two seconds. Network requests do not count against it — the host stops the clock while one is in flight.
  2. No runtime code generation. eval, new Function, importScripts, computed or remote import(), WebAssembly.compile/instantiate, and a string passed to setTimeout are refused at publish and absent or stripped in the worker.
  3. No storage of your own. Persist through your settings: note or a note in the vault.
  4. A crash is contained. An exception marks the plugin crashed and leaves the app running. The log is in the plugins palette.
  5. Readable source only. Minified or bundled code is rejected — a reviewer reads what you upload, and if they cannot read it, it cannot be published.

Dependencies

There is no package manager. Ship what you need as readable files in your bundle and import them with a relative path. A vendored minified library is rejected for exactly the same reason minified code of your own is.

10Testing and debugging

  • Logs. console.log from the worker lands in the plugin's log, visible in ⌘⇧P. This is the primary debugging tool and it never leaves the app.
  • Reload plugins re-reads your code without restarting Slate.
  • A crashed plugin says why. The status turns to crashed and the error sits in the log.
  • Test the real install path. Serve your bundle over HTTPS and use Install a plugin from a URL…. If it installs that way it will install for everyone else, because it is the same code path the marketplace uses.

11A worked example

A status item and a window, one network host, a settings note, and a narrow write scope — most of what a real plugin uses:

// manifest.json
{
  "schemaVersion": 1,
  "apiVersion": "slate.v1",
  "id": "alice.standup",
  "name": "Standup",
  "version": "1.0.0",
  "author": "alice",
  "license": "MIT",
  "description": "Yesterday's notes, today's tasks, and anything blocked.",
  "category": "tasks",
  "maturity": "stable",
  "kinds": ["window", "status-item"],
  "entryPoints": { "window": "window.js", "statusItem": "status.js" },
  "settings": ".slate/plugins/standup.md",
  "permissions": ["vault:read:journal/**", "vault:write:journal/**"]
}
// window.js
const config = slate.settings.get()
const FOLDER = config.folder ?? 'journal'

function yesterday() {
  const d = new Date()
  d.setDate(d.getDate() - 1)
  return d.toISOString().slice(0, 10)
}

slate.registerWindow({
  id: 'main',
  title: 'Standup',

  async render() {
    const path = `${FOLDER}/${yesterday()}.md`
    let text = ''
    try {
      text = await slate.vault.read(path)
    } catch {
      // A missing note is an answer, not a failure.
      return [{ t: 'empty', value: `Nothing written on ${yesterday()}` }]
    }

    const done    = [...text.matchAll(/^- \[x\] (.+)$/gim)].map((m) => m[1])
    const open    = [...text.matchAll(/^- \[ \] (.+)$/gim)].map((m) => m[1])
    const blocked = open.filter((t) => /blocked|waiting/i.test(t))

    return [
      { t: 'section', title: 'Yesterday' },
      ...(done.length
        ? done.map((d) => ({ t: 'row', c: d, tone: 'success' }))
        : [{ t: 'empty', value: 'Nothing finished' }]),

      { t: 'section', title: 'Today' },
      ...open.map((o) => ({ t: 'row', c: o })),

      ...(blocked.length ? [
        { t: 'section', title: 'Blocked' },
        ...blocked.map((b) => ({ t: 'row', c: b, tone: 'warning' })),
      ] : []),

      { t: 'divider' },
      { t: 'button', label: 'Open yesterday', action: { command: 'open' } },
    ]
  },

  onKey(key) {
    if (key !== 'o') return false
    void slate.ui.open(`${FOLDER}/${yesterday()}.md`)
    return true
  },
})

slate.registerCommand({
  id: 'open',
  title: 'Standup: open yesterday',
  hidden: true,
  run: () => slate.ui.open(`${FOLDER}/${yesterday()}.md`),
})

Note what it does not do: it asks for journal/** rather than the vault, it handles a missing note instead of throwing, and it puts its one configurable value in a settings note rather than inventing a preferences screen.