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 plugin that runs, in about ten minutes.
  • 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 members, and this is all of them. 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 unattributed, around tenon.log's 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.

This is deliberate and has a stated reason: a language model should be able to read these docs and write a working plugin on the first try. A large share of the people extending an agent-supervision tool are agents.

The sender rule

One convention runs through everything, and getting it right is most of writing a correct plugin:

A function that sends takes the sender as its last parameter, defaulting to tenon.intents, and always calls await call.send(name, input, options). A handler passes its own call into every function it calls that sends.

js
async function greet(name, call = tenon.intents) {
  return await call.send("ui.toast.v1", { message: `Hello, ${name}`, kind: "success" })
}

tenon.intents.handle("dev.example.greeter.greet.v1", (input, call) => {
  call.throwIfCancelled()
  return greet(input.name, call)          // pass `call` through
})

Passing call keeps that invocation's workspace and pane targeting, clamps the work to the parent deadline, joins the causal chain used for cycle and depth accounting, and cancels it with the invoking command.

Omitting it sends under the ambient focused workspace and pane, as a fresh root request with its own budget — which is what a long supervised run started from a short-deadline command actually wants.

Either way the request runs under your plugin's declared authority. Caller authority is never inherited.

Building blocks