Skip to content

Writing a plugin ​

A plugin is one directory:

text
my-plugin/
  manifest.json
  main.js

It runs in an isolated JavaScriptCore context, sees exactly one host global called tenon, and reloads when you save. Bundled and third-party plugins get the same surface — there is no private door.

Start here ​

  • Quickstart — a complete working plugin that adds a palette action and runs a real command.
  • The manifest — everything declared before your JavaScript is evaluated, and why.
  • Choosing a mechanism — the decision order that keeps you out of trouble. Read this once, early.

The whole public surface ​

Thirty callable members, plus the apiVersion metadata member. A test pins this exact list, so it cannot drift underneath you.

GroupMembersWhat it is
metadataapiVersionimmutable runtime version
intentsintents.send, intents.handle, intents.listfinite cross-owner requests
eventsevents.on, events.emitimmutable facts, no reply
viewsviews.register, views.set, views.onSelect, views.onSubmit, views.onOpen, views.onClosedeclarative pane content
palettepalette.registerProvider, palette.setResults, palette.onQuerydynamic palette results
statusstatusBar.setone status contribution
timerstimers.after, timers.every, timers.cancelcaller-owned schedules
processprocess.streama long-running command
fsfs.watchfilesystem watch
settings / storage / logsettings.get, storage.get, storage.set, logthe closed scoped-facility allowlist
pathpath.join, path.normalize, path.basename, path.dirname, path.extnamepure strings, no I/O
agentsagents.runsupervised run-to-result over your own declared intents

Notice what is not there: no filesystem read, no process exec, no terminal write, no clipboard, no network, no UI prompt. Those are all real capabilities and they all arrive as canonical intents instead of handwritten helpers.

What plugins cannot see ​

require, setTimeout and fetch were never in scope. console is deleted by the bootstrap — logging through it would reach the system log without per-plugin attribution.

A second test pins Object.getOwnPropertyNames(globalThis) to exactly the closed set, so a new global from a future JavaScriptCore fails the suite instead of quietly widening the boundary. See The plugin boundary.

One API shape everywhere ​

Every asynchronous call resolves once to a result envelope:

js
const result = await tenon.intents.send(name, input, options)
if (!result.ok) {
  tenon.log("failed:", result.error.code)
  return
}
use(result.value)

No callbacks-or-promises duality, no throwing-or-returning duality. Load-time errors offer suggestions rather than leaving you with a silent undefined.

The single async shape and explicit load-time errors keep plugin code predictable. For the sender convention — including when to pass a handler's call through — see Sending intents.

Building blocks ​