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.
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:
| Kind | What it is | Entry point key |
|---|---|---|
command | Entries in the ⌘P palette | command |
window | A view, floating or docked in a pane | window |
status-item | A segment in the statusline | statusItem |
service | Background work with no UI of its own | service |
panel | A docked side panel | panel |
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": "…" }
}
.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
| Field | Rules |
|---|---|
schemaVersion | Exactly 1. |
apiVersion | Exactly "slate.v1". |
id | Lowercase, 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. |
name | 2–60 characters. What a person calls it. |
version | Semver: 1.0.0. Published versions are immutable, so this must go up every time. |
author | Your name or handle. |
description | 10–200 characters, one sentence, says what it does. |
kinds | A non-empty array from the table in §1. |
entryPoints | One file per kind, keyed as in §1. The file must exist in the bundle. |
permissions | An array — [] if you need nothing. |
Optional
| Field | Rules |
|---|---|
category | One of capture, knowledge, tasks, dev, view, sync, fun. Required to publish. |
maturity | stable (default), experimental, or example. The last two are badged in the browser. |
license | SPDX id. Expected by review. |
screenshots | Up 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. |
settings | Vault 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.
| Permission | Grants | Shown 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, commands | one sentence per scope |
terminal | An interactive shell as your user | “Open an interactive shell with your user account” |
Rules that are enforced, not advised
- A bare
vault:readorvault:writemeans**— the whole vault. Say what you actually need:vault:read:journal/**is treated as low risk wherevault:readis not. network:**andsecret:**are rejected. Name the host. Matching is exact —network:open-meteo.comdoes not grantapi.open-meteo.com, and a plugin that needs the subdomain must name the subdomain.httpsonly, excepthttptolocalhostand127.0.0.1, because a local model has no certificate to offer.- Nothing reaches
.slate/. Not withvault:write:**, not with any glob. The single exception is your ownsettings: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.
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.*
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.
9Rules the runtime enforces
- 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.
- No runtime code generation.
eval,new Function,importScripts, computed or remoteimport(),WebAssembly.compile/instantiate, and a string passed tosetTimeoutare refused at publish and absent or stripped in the worker. - No storage of your own. Persist through your
settings:note or a note in the vault. - A crash is contained. An exception marks the plugin
crashedand leaves the app running. The log is in the plugins palette. - 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.logfrom 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
crashedand 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.