<!-- SPDX-License-Identifier: Apache-2.0 -->

# Plugins: developer guide

You are writing a plugin. This document covers the SDK surface, authoring conventions, and the isolation model of the C# runtime.

A plugin is a JavaScript package that runs in-process in a constrained JavaScript engine.
It is trusted host code: give the agent useful capability without silently widening the blast radius for the operator.

If you are installing a plugin, see [Plugins](../configuration/plugins.md).

## Trust model

Plugins run inside the runtime process, isolated only by engine limits, not by an operating-system boundary.
They can make network requests and read files through the host, so install and run only code you trust.

A plugin is allowed to participate in the runtime; it is not contained the way an agent's tool use is contained.

## Starting a plugin

A plugin is an ECMAScript module that imports the embedded `@cireilclaw/sdk` module and exports a `default` factory.

The runtime resolves the entry module from the package `exports` map, the `main` field, or the default `dist/index.mjs`, `dist/index.js`, `index.mjs`, or `index.js` files.

```js
export default definePlugin(() => ({
  name: "example",
  tools: { ... },
}));
```

The factory must return a plugin object with a non-empty `name`.
The `tools` object is optional, so a plugin may provide only extractors.

## Authoring a plugin

```js
import { definePlugin, ToolError, vb } from "@cireilclaw/sdk";

const SearchSchema = vb.strictObject({
  query: vb.pipe(vb.string(), vb.nonEmpty()),
  count: vb.optional(vb.pipe(vb.number(), vb.integer(), vb.minValue(1), vb.maxValue(20)), 5),
});

export default definePlugin(() => ({
  name: "example",
  tools: {
    "example-search": {
      description: "Search for stuff.",
      parameters: SearchSchema,
      async execute(input, ctx) {
        const parsed = vb.parse(SearchSchema, input);
        const config = await ctx.cfg.globalPlugin("example");
        if (config?.apiKey === undefined) {
          throw new ToolError("example plugin is not configured");
        }
        const response = await ctx.net.fetch(
          `https://example.com/search?q=${encodeURIComponent(parsed.query)}`,
          { headers: { Authorization: `Bearer ${config.apiKey}` } },
        );
        if (!response.ok) throw new ToolError(`example API returned ${response.status}`);
        return { success: true, results: await response.json() };
      },
    },
  },
}));
```

The tool object key is the tool name.
A `name` field inside the tool object is ignored.
`description` must be a non-empty string, and `parameters` should be a `vb` schema; the runtime converts it to a JSON Schema for the model at load time.

Conventions:

- Validate input with `vb.parse` inside `execute`; the runtime does not pre-validate.
- Throw `ToolError` for anything the model should see as a failed tool call.
- Return `{ success: true, ... }` for success; on failure throw `ToolError` or return `{ success: false, error, hint? }`.
- Use `ctx.net.fetch`, not a browser `fetch`, so future network mediation applies.
- `execute` may be synchronous or async.

## SDK exports

The published surface is authored in the `sdk/` bundle of this repository and shipped to npm as `@cireilclaw/sdk` for plugin typechecking and editor support.
The runtime embeds its own implementation of the same surface, so installed plugins never need the npm package at run time.

`@cireilclaw/sdk` exports:

- `definePlugin(factory)` — identity helper that verifies the argument is a factory function.
- `ToolError` — semantic tool-failure error carrying an optional `hint`.
- `vb` — a small schema builder for tool parameters and validation.
- `toJsonSchema(schema)` — converts a `vb` schema to JSON Schema.
- `KeyPool`, `KeyPoolManager` — API key rotation with cooldown.
- `pemToDer`, `base64urlEncode`, `base64urlDecode` — encoding helpers.
- `toJpeg`, `toWebp`, `scaleForAnthropic` — host-mediated image conversion helpers.

## Schema validation

`vb` provides `string()`, `number()`, `boolean()`, `unknown()`, `literal(value)`, `array(value)`, `tuple(value)`, `record(key, value)`, `union(value)`, `picklist(value)`, `enum(value)`, `enum_(value)`, `object(value)`, `partial(value)`, `objectWithRest(value, rest)`, `strictObject(value)`, `looseObject(value)`, `optional(input)`, `nullable(input)`, `exactOptional(input, defaultValue)`, `pipe(input, ...actions)`, `nonEmpty()`, `integer()`, `description(value)`, `message(value, message)`, `regex(value, message?)`, `email(message?)`, `url(message?)`, `minLength(value)`, `minValue(value)`, `maxValue(value)`, `check(predicate, message?)`, `transform(transformer)`, `parse(schema, input)`, and `safeParse(schema, input)`.

`exactOptional` keeps a default value when the key is absent, matching the parameter behavior of the source runtime.

## The invocation context

Every tool's `execute(input, ctx)` receives a context with host-mediated capabilities.

| Member | Shape |
| --- | --- |
| `ctx.agentSlug` | string |
| `ctx.session.channel` | string |
| `ctx.session.id()` | function returning the session id |
| `ctx.mounts` | array of `{ mode, source, target }` |
| `ctx.reply.send(content, attachments?)` | Promise |
| `ctx.reply.react(emoji, messageId?)` | Promise; unsupported channels reject |
| `ctx.channel.resolveChannel(spec)` | Promise of `{ channel, id() }` or `{ error }` |
| `ctx.cfg.globalPlugin(name)` | Promise of config object or `undefined` |
| `ctx.cfg.agentPlugin(name)` | Promise of config object or `undefined` |
| `ctx.net.fetch(url, options?)` | Promise of `{ ok, status, statusText, headers.get, text(), json(), arrayBuffer() }` |
| `ctx.fs.readTextFile(sandboxPath)` | Promise of string |
| `ctx.fs.writeTextFile(sandboxPath, content)` | Promise |
| `ctx.fs.stat(sandboxPath)` | Promise of `{ ctimeMs, isDirectory, isFile, mtimeMs, size }` |
| `ctx.fs.listDir(sandboxPath)` | Promise of dirent array |
| `ctx.paths.resolve(sandboxPath)` | Promise of the host path this sandbox path maps to, without resolving symbolic links |
| `ctx.paths.checkWriteAccess(sandboxPath)` | Promise; rejects when not writable |
| `ctx.paths.checkConditionalAccess(sandboxPath)` | Promise; rejects when the session context denies access |
| `ctx.createKeyPool(keys, cooldownMs?)` | `KeyPool`; default cooldown 30 minutes |
| `ctx.crypto.randomBytes(length)` | Promise of `Uint8Array` |
| `ctx.crypto.hkdf(ikm, salt, info, length)` | Promise of `Uint8Array`; SHA-256 |
| `ctx.crypto.ed25519.generateKeyPair()` | Promise of `{ privateKeyPkcs8, publicKey }` |
| `ctx.crypto.ed25519.sign(privateKeyPkcs8, data)` | Promise of signature |
| `ctx.crypto.ed25519.verify(publicKey, signature, data)` | Promise of boolean |
| `ctx.crypto.x25519.generateKeyPair()` | Promise of `{ privateKeyPkcs8, publicKey }` |
| `ctx.crypto.x25519.derive(privateKeyPkcs8, publicKey)` | Promise of shared secret |
| `ctx.crypto.xchacha20poly1305(key, nonce, aad?)` | `{ encrypt(plaintext), decrypt(ciphertext) }` |
| `ctx.crypto.loadNormalizedKey(options)` | Promise of `{ data, format }` |
| `ctx.discord.setStatus(status)` | Promise; rejects on channels without presence |
| `ctx.discord.setPresence(message)` | Promise; `null` clears the status message |
| `ctx.ids.ulid()` | Promise of a ULID string |
| `ctx.pluginState.readText(name)` | Promise of string or `undefined` |
| `ctx.pluginState.writeText(name, content)` | Promise |
| `ctx.pluginState.remove(name)` | Promise |
| `ctx.addImage(data, mediaType)` | fire-and-forget |
| `ctx.addVideo(data, mediaType)` | fire-and-forget |
| `ctx.addToolMessage(content)` | fire-and-forget |

Notes:

- `session` is deliberately narrow; plugins do not see conversation history.
- `paths.*` and `ctx.fs.*` operate on sandbox paths such as `/workspace/file.txt`.
- `createKeyPool` pools are in-memory and do not survive runtime restarts.
- Fire-and-forget callbacks do not await delivery; call them early if delivery matters.
- Key pools and `ctx.ids.ulid()` are provided by the host, so plugins do not need to ship crypto or id dependencies.

`ctx.discord.*` updates the presence of the agent's Discord gateway connection; see Discord presence below.

`ctx.net.fetch` also exposes `arrayBuffer()`, which returns the response body as a `Uint8Array`.

## File outline extractors

A plugin may return an `extractors` array in addition to, or instead of, `tools`.

Each extractor has a `glob`, an optional integer `priority` defaulting to `0`, and an `extract(filePath, content)` function.
The glob is matched against the file name, higher priorities run first, and the function may be synchronous or asynchronous.
The function must return an array of section objects with `id`, one-based `line`, `type`, `label`, and `lines` fields.

Extractors run only for files large enough to require an outline, and the runtime checks matching extractors in descending priority order.
They are registered when the plugin loads and do not need to be enabled as tools in an agent's `tools.toml`.

## Crypto environment

Byte inputs and outputs are `Uint8Array`.
Generated private keys are PKCS#8 DER and public keys are raw 32-byte values.
`crypto.subtle.importKey` accepts `pkcs8`, `spki`, and `raw` formats and returns a host key object.
`crypto.subtle.sign` accepts `RSASSA-PKCS1-v1_5` or `RSA-PSS` with SHA-1, SHA-256, SHA-384, or SHA-512.
RSA-PSS requires `saltLength` to equal the selected hash digest length.

## Discord presence

`ctx.discord.setStatus(status)` sets the account presence status shown by Discord: `"online"`, `"idle"`, `"dnd"`, or `"invisible"`.
`"offline"` is accepted and maps to `"invisible"`, because the gateway rejects an explicit offline presence.
`ctx.discord.setPresence(message)` sets the custom status message shown under the account, as a string of at most 128 characters.
Pass `null` to clear the status message.

Presence is per gateway connection, not per session, so the update applies to the whole Discord client of the agent's runtime regardless of which session invoked it.
The desired status and message are persisted for the agent and re-applied after a CireilClaw restart, Discord reconnect, and periodically while the runtime runs, because Discord does not guarantee that a presence survives a gateway session reset and a dropped status is not reported as an event.
On channels without presence support (anything other than a running Discord gateway), both calls reject with `presence not supported on this channel`.

## Plugin state

`ctx.pluginState` is private, persistent per-`(agent, plugin)` storage.
Files live under `agents/<slug>/state/<plugin-slug>/`; the plugin supplies only a relative name.

Writes are atomic and owner-only.
A per-plugin quota (default 16 MiB, overridable with `stateQuotaBytes` in `plugins.toml`) caps total usage.
The plugin never sees the host path; state is not reachable from `ctx.fs` and not visible to sandboxed commands.

## Configuration

Plugins read configuration through `ctx.cfg.globalPlugin(name)` and `ctx.cfg.agentPlugin(name)`.

By convention these live at `config/plugins/<name>.toml` and `agents/<slug>/config/plugins/<name>.toml`.
The plugin decides what `<name>` it reads and which keys it expects.
Config files are re-read on access, so edits take effect on the next tool invocation.

## Tool collisions and overrides

A plugin tool that collides with a built-in tool fails loudly at startup unless the plugin entry sets `allowOverride = true`.
Two plugins exposing the same tool name always fail regardless of override flags.

Plugin tools must be enabled per agent in `tools.toml`, exactly like built-in tools.

## Isolation and limits

Each plugin runs in its own constrained JavaScript engine inside the runtime process.

- Memory is capped at 16 MiB.
- Statement execution is capped at 1,000,000 statements.
- A single tool call times out after 10 minutes.
- Only one tool invocation runs per plugin at a time.

This gives crash isolation for misbehaving plugins, but not an operating-system security boundary.
A plugin has no access to the .NET runtime or host process APIs; the available JS environment is the SDK plus `TextEncoder`, `TextDecoder`, `URL`, `URLSearchParams`, and the limited `crypto.subtle`.

## Changes require a restart

The plugin list and plugin code are loaded at startup.
Changes to `plugins.toml` or to plugin files require a full runtime restart; there is no hot reload.

## Debugging

A `ToolError` thrown in `execute` surfaces to the agent as a tool failure with its message and optional hint.
Other JavaScript errors propagate as tool failures too.
A plugin that fails to load prevents the runtime from starting; check the runtime logs for the reported cause.

## Known limitations

- No plugin lifecycle hooks such as `onStart` or `onShutdown`.
- No per-plugin network or filesystem sandboxing.
