Skip to content

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.

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.

MemberShape
ctx.agentSlugstring
ctx.session.channelstring
ctx.session.id()function returning the session id
ctx.mountsarray 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.

Are you an agent? Prefer this page's raw Markdown document: follow its text/markdown alternate link, use its .md URL, or add ?md=1 to the page URL.