Plugins
Out of the box the assistant can reach the network, work with workspace files and run small scripts. To connect it to your service, you add a plugin: a folder with a manifest and some JavaScript. The code runs in the WASM sandbox, and every call it makes leaves through a base tool that gates itself — so a plugin is meaningfully safer than handing the assistant a shell.
The shape of one
Section titled “The shape of one”nocturn-data/workspaces/main/extensions/my-api/ plugin.json ← what it offers, and which base tools it may call plugin.js ← the codeBoth are read at startup. A malformed plugin is skipped with a diagnostic rather than aborting anything — its tools and credentials are simply absent, which is the fail-closed direction.
Declare it
Section titled “Declare it”{ "name": "my-api", "version": "1", "tools": [ { "name": "send", "description": "Send a message to the API", "parameters": { "type": "object", "properties": { "msg": { "type": "string", "description": "The message" } }, "required": ["msg"] } } ], "uses": ["http_write"]}| Field | Meaning |
|---|---|
name |
The plugin’s identity. Its tools appear to the model as my-api_send. |
version |
Required, any string. |
tools |
What the model sees: name, description, and a JSON-Schema object for the parameters. |
uses |
The cage — the base tools this plugin’s code may call. ["*"] admits all; omit it entirely for a pure-compute plugin that reaches nothing. |
credentials |
Optional: credentials the host injects on its behalf (name, host, header, prefix). |
oauth |
Optional: an OAuth2 provider the host runs for it. |
The manifest is parsed strictly: an unknown field is an error, not a warning. That is on purpose — a typo’d permission field must never be silently ignored.
uses is the part worth dwelling on. It is not a filter applied to calls; it is the set of tools
the plugin’s guest has. A plugin that lists only http_write does not have a file tool that gets
denied — it has no file tool. Nothing in the prompt, and nothing in the plugin’s own code, can
conjure one.
Write it
Section titled “Write it”globalThis.plugin = { tools: { send: async (args) => { const r = await fetch("https://api.example.com/messages", { method: "POST", body: JSON.stringify({ text: args.msg }), }); if (!r.ok) throw new Error("send failed: " + r.status); return await r.text(); }, },};fetch is the prelude’s wrapper over http_read / http_write, so this call goes through the
ordinary gate: the host is approved by you, per request, exactly as if the model had asked for it.
Outside the cage there is nothing to call; inside it, the gate still applies. Both walls, every
time.
The same prelude gives you nocturn.fs.*, nocturn.ping, nocturn.resolve, nocturn.notify,
nocturn.remind, nocturn.now and a node-ish require("fs") shim — each mapping to the base tool
of the same name, and each available only if your uses list includes it.
Credentials
Section titled “Credentials”Never put a secret in a plugin. Declare it instead, and the host attaches it at the boundary:
"credentials": [ { "name": "my-api", "host": "api.example.com", "header": "Authorization", "prefix": "Bearer " }]| Field | Meaning |
|---|---|
name |
Required. What you store the value under, and what an oauth block links to. |
host |
Required. The only host it is ever attached to. A request anywhere else does not carry it. |
header |
Required. Which header it is stamped into — usually Authorization. |
prefix |
Optional, prepended to the value: "Bearer " for a bearer token, empty for a bare key. |
Then store the value once:
printf %s "$TOKEN" | nocturn secret set plugin:my-api/my-apiYour code never sees the token — it is stamped in host-side, for that host only. Where that value is kept, how the plugin’s shard is keyed, and what happens if it tries to leave is on the vault.
For a service you log into rather than paste a key from, declare an oauth provider. The host runs
the flow, holds the token, refreshes it, and injects it through the credential of the same name:
"credentials": [ { "name": "gcal", "host": "www.googleapis.com", "header": "Authorization", "prefix": "Bearer " }],"oauth": [ { "name": "gcal", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "client_id": "…apps.googleusercontent.com", "client_secret": "", "scopes": ["https://www.googleapis.com/auth/calendar.readonly"] }]| Field | Meaning |
|---|---|
name |
Required, and must match a credentials entry. That is what the token is injected through — a block with no matching credential fetches a token nothing would ever use, and is refused at load. |
auth_url |
Required, https. Where you are sent to consent. |
token_url |
Required, https. Where the code is exchanged, and where refreshes go. |
client_id |
Required. The client you registered with that provider. |
client_secret |
May be "". A desktop-app client’s secret is not confidential and is shipped in the manifest; a PKCE client has none at all. |
scopes |
At least one. |
oauth is a list, so one plugin can bring several providers — each pairing with its own
credential by name.
Two differences from an MCP server, both worth knowing before you
copy one into the other. A plugin always writes its endpoints out by hand: there is no discovery
mode, so client_id and scopes live in the manifest rather than arriving as a -scope flag at
sign-in time. And the name here does double duty as the link to a credential, which an MCP block
has no equivalent of — an MCP server is the destination, while a plugin declares which of its
credentials the token becomes.
Then connect it once:
nocturn auth gcalThat prints a consent URL, stores the token in the plugin’s own encrypted shard, and refreshes it from then on. Like a plugin itself, it takes effect at the next start.
This is a command-line step. The app’s Settings → Accounts lists MCP servers in discovery mode and nothing else, so a plugin’s provider does not appear there — connect it from a terminal. See the vault for where the token then lives.
Putting one in, end to end
Section titled “Putting one in, end to end”The repository ships a runnable one — a weather plugin with a single tool, caged to http_read:
cp -r examples/workspace/extensions/weather \ nocturn-data/workspaces/main/extensions/weather
nocturn serve # start, or restart if it was already runningThat is the whole install. Two things follow from it:
Read plugin.json first, and read it instead of plugin.js. The manifest is the complete
statement of what the plugin can reach — "uses": ["http_read"] here — and you can check it without
running anything. The code cannot widen it.
Tell the server to look again. Plugins are discovered when a workspace opens, and there is no
watcher on extensions/ — a server that was already running does not see the new folder by itself:
nocturn reload # or -w <workspace>It re-reads agents, skills, plugins and MCP servers, and prints what the workspace holds afterwards. Conversations already open keep running; the new tools are there from their next message. Restarting works too and is the bigger hammer.
Removing one is the reverse gesture, from Settings → Plugins: the folder goes, any account connected for it goes with it, and the remembered permission to reach its hosts is revoked — a permission left standing for a program that is gone would be inherited by the next thing to reach that host.
Its tool then appears to the model as weather_forecast, and its first request asks about the host
it wants, exactly as if the model had called http_read itself.
Installing one from the catalog
Section titled “Installing one from the catalog”The library offers plugins too, and installing one from the app writes the same
folder this page has been describing — manifest, plugin.js, and the SKILL.md it bundles if it
brought one. The entry shows what the manifest asks for before the button: the tools it adds, the
base tools its guest may call, the hosts a credential would ride to, and the scopes a sign-in would
request.
A plugin entry from a remote catalog must carry an Ed25519 signature by a key compiled into the daemon, or it is not offered at all. That is the line between the two things a catalog carries: a skill is text, and TLS to one host is a proportionate control for text; a plugin is code, and code is held to a key. The signature covers the identity and every artifact digest together, so a signed script cannot be re-fronted with a manifest that asks for a credential.
The signature covers the listing and a serial as well as the artifacts. The listing, because you pick by the title and description, and a host that had been taken over could otherwise rebrand a signed plugin while its code stayed the one we signed. The serial, because a signature says “we published these bytes” and never “this is current” — your daemon remembers the highest serial it has accepted per plugin and refuses to go back, so an old signed entry cannot be served to you forever.
What signing does not do is read the manifest for you. It says who published these bytes, not that what they ask for is what you meant to grant — the danger note above still applies, and the entry shows the manifest for exactly that reason.
A catalog read from a file on this machine needs no signature: there is no channel to
authenticate, and you could have copied the folder in by hand. That is also how you serve your own
plugins — see examples/catalog/README.md,
or sign with your own key and trust it with NOCTURN_CATALOG_DEV_KEY.
When to reach for one
Section titled “When to reach for one”Use a plugin when you would otherwise wish the assistant had a CLI for some service. You get the same reach, but the token stays with the host, the plugin can only call the base tools it named, and every actual call still asks you.
Prefer not to write code? A hosted MCP server gives you tools with no plugin at all.