Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nexa-plugin

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 esbuild

A plugin

Two 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 build

That 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.

The manifest is enforcement, not documentation

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.

Settings

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.

Your plugin runs in its own thread

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.

  • assessRisk is synchronous, so a threaded tool is gated on its declared risk. Declare the worst case. The host logs a warning naming any tool that ships a refinement it cannot use.
  • summarize likewise, 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.

Building

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.

esbuild is optional

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.

Examples

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.

License

Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages