The typed SDK for writing Nexa plugins, and the compiler that
turns one into a .nexa file.
A plugin can add tools the agent calls, slash commands, chat platforms it talks on, and hooks that observe a turn. Installing one is dropping a file in a folder.
npm install --save-dev nexa-plugin esbuildTwo files. nexa.plugin.json says what the plugin is, and the entry module registers it.
{
"id": "weather",
"name": "Weather",
"version": "1.0.0",
"entry": "./src/index.ts",
"contributes": { "tools": ["weather_lookup"] },
"permissions": {
"network": ["api.weather.test"],
"filesystem": "none",
"exec": false,
"secrets": ["weather_key"]
},
"compat": { "pluginApi": "^0.1.0" },
"configSchema": {
"type": "object",
"properties": { "units": { "type": "string", "default": "metric" } }
}
}import { definePlugin, RiskLevel } from 'nexa-plugin';
export default definePlugin({
id: 'weather',
activate(api) {
api.registerTool({
name: 'weather_lookup',
description: 'Current conditions for a city. Use when asked about weather.',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
risk: RiskLevel.Read,
async execute(input) {
const key = await api.runtime.secret('weather_key');
const units = api.config['units'] ?? 'metric';
const reply = await api.runtime.fetch(
`https://api.weather.test/v1?q=${input.city}&units=${units}&key=${key}`,
);
return { status: 'ok', content: await reply.text() };
},
});
},
});npx nexa-plugin buildThat writes weather.nexa. Copy it into ~/.nexa/plugins/ and start Nexa; it is discovered, its
settings file is written to ~/.nexa/plugins/weather/config.json, and the agent can call the tool.
Registering a name contributes does not list is refused, and the refusal discards the whole
activation rather than warning about it. That is what makes the manifest true by construction: a
settings page reading manifests alone is reading the truth, and a compromised update that starts
registering read_file is stopped by a data check rather than by a review that may not happen.
The same applies to permissions. api.runtime.fetch refuses a host the manifest did not declare,
api.runtime.secret resolves only declared ids, and api.runtime.exec is absent unless exec is
true.
Be honest with yourself about what that is. It is a declaration a reviewer can check against the
code, and real enforcement at the seams the host owns. It is not a sandbox: a plugin can import
node:fs and node:child_process directly and the thread it runs in has the process's own
filesystem and network access. Nexa's install-time scan flags the gap between a manifest and the
code beside it, which is what catches the honest mistakes.
configSchema generates config.json in the plugin's directory the first time Nexa sees the plugin,
with every property that has a default filled in. The operator edits that file; the values arrive
as api.config.
The file belongs to the operator. Defaults merge under it and never over it, a schema that gains a
key adds that key alone, and an update carries the file across rather than replacing it. A property
with no default is deliberately left out of the generated file: an empty string written for a
required API key produces a file that looks configured and fails at runtime, where an absent key
fails validation by name and says what to type.
By default, and it is worth knowing what that buys and what it costs.
It buys fault isolation. A plugin that throws takes down a thread and the supervisor brings it back; one that spends four seconds in a regex stalls nothing else; one that cannot stay up is quarantined and reported rather than respawned all day. In-process, each of those is an outage for every conversation the daemon is serving.
Two things do not cross the boundary, and both for the same reason: a structured clone is not a function, and there is no synchronous call across a thread.
assessRiskis synchronous, so a threaded tool is gated on its declaredrisk. Declare the worst case. The host logs a warning naming any tool that ships a refinement it cannot use.summarizelikewise, and the approval prompt falls back to the tool name and its arguments.
Everything else crosses, including the two shapes that look like they could not. A streamed
message's MessageHandle stays in your thread and the host drives it by reference, so edit and
finish reach the object you created. A provider's completion generator is pulled one event at
a time, which keeps the consumer setting the pace instead of letting a fast model grow a queue in
the host's heap, and your finally runs when a turn is cancelled.
nexa-plugin build [dir] [options]
--out <file> where to write. Defaults to <dir>/<id>.nexa
--from <file> use this already-bundled CommonJS file instead of running esbuild
--no-source ship only V8 bytecode
--no-compile skip the V8 code cache
--minify minify the bundled output
A .nexa file carries the manifest, the bundled JavaScript, and V8's compiled form of it. The
manifest sits in a header the host reads without executing anything, which is what lets Nexa decide
whether to run a plugin from data alone.
The code cache is an accelerator and never the source of truth. V8 accepts one only from a build matching its own, which is what happens on every Node upgrade, so the default bundle carries source and a rejected cache costs a recompile and a debug line.
--no-source produces the bytecode-only artifact and behaves as that implies: on a V8 whose cache
does not match there is nothing to fall back to, and the plugin refuses to load with both versions
named. It is not encryption. V8 bytecode is reversible with public tooling, so it raises the
effort of reading a plugin and does not stop it. A secret inside one is a shipped secret.
build uses esbuild to roll the plugin and its dependencies into one file. If you would rather not
have a bundler, build the single CommonJS file yourself and pass it with --from.
The artifact must be self-contained. The host's loader resolves Node builtins and nothing else, and refuses anything left external by name, so a dependency that did not make it in fails loudly at load rather than as a missing module from inside someone else's package.
Two complete plugins live in examples/plugins: hello, which is plain
ESM with no build step, and whatsapp, which is a channel plugin written in TypeScript against this
package and built into a single .nexa file.
Apache-2.0