Plugins

Model implementations, tools, audit sinks, connectors and background services use one RuntimePlugin protocol and one per-Harness PluginHost. These are code-architecture plugins, not MCP servers. A plugin can contribute several capabilities; the system does not assign mutually exclusive plugin categories.

The Agent loop, Session, permissions and registries stay in the engine. Existing declarative oharness.plugin.json bundles remain supported and are never automatically imported as executable modules.

Quick start

Run the four-plugin demonstration without credentials or network calls:

npm run demo:plugins
npm run test:plugins

The examples in script/fixtures/runtime-plugins.ts implement a scripted model, a local tool, an audit observer and a mock connector. They are demonstrations. The hosted [Cloud connector](cloud-connectors.md) is registered as connector-cloud by default outside ephemeral instances. It registers two tools without network access at boot; missing login or server configuration becomes a tool error, not a startup failure. Disable it with { "id": "connector-cloud", "enabled": false } in plugins.json's runtimePlugins. Embedders can set cloudConnectors: false. The direct [Composio adapter](composio-plugin.md) remains an opt-in developer alternative; do not enable it in hosted clients. Pipedream is not implemented.

The built-in [Memory plugin](memory-sync.md), ID memory, now owns the memory tool, per-turn prompt index, web page and background sync lifecycle. It is active even in ephemeral Harness instances (matching previous local-memory behavior). Disable it in plugins.json to remove all these contributions. This is separate from turning off cloud synchronization while retaining local memory.

Author a plugin

import type { RuntimePlugin } from "@oharness/engine";

export default {
  id: "hello-tools",
  version: "1.0.0",
  apiVersion: 1,
  parseConfig(raw) {
    if (raw !== undefined) throw new Error("This plugin takes no options");
    return {};
  },
  setup(ctx) {
    ctx.tools.register({
      name: "hello_greet",
      description: "Return a greeting",
      inputSchema: { type: "object", properties: {} },
      readOnly: true,
      async execute() {
        return { content: [{ type: "text", text: "Hello" }] };
      },
    });
  },
} satisfies RuntimePlugin;

setup runs once per Harness instance. Keep clients, timers and mutable state inside it, not at module scope. Plugin IDs use lowercase letters, numbers, dots, underscores or hyphens. API version 1 is checked independently of the plugin's own version.

Load and configure

An embedding app may explicitly supply trusted plugin definitions:

import { Harness } from "@oharness/engine";
import greeting from "./hello-plugin.js";

const harness = await Harness.create({
  runtimePlugins: [{ plugin: greeting, required: true }],
});
try {
  console.log(harness.runtimePluginStatus());
  // Create sessions / agents as usual.
} finally {
  await harness.close();
}

Alternatively, add runtimePlugins to the user plugin file (plugins.json in OHARNESS_HOME, normally ~/.oharness). An absolute module path or an installed package resolvable from that directory is accepted; nothing is downloaded. Modules must default-export a plugin definition. Prefer built JavaScript for distribution; source TypeScript requires Node's supported type-stripping subset.

{
  "runtimePlugins": [
    {
      "id": "hello-tools",
      "module": "/absolute/path/to/hello-plugin.js",
      "enabled": true,
      "required": true
    }
  ]
}

config is passed to parseConfig. A configured entry with the same ID can configure/disable an inline plugin, but cannot replace its implementation with another module. Duplicate IDs within either list are rejected. enabled: false skips module import, config parsing and setup entirely. required defaults to true; optional failures are visible in status without aborting Harness creation. The configuration list replaces an earlier list rather than deep-merging plugin options. Changes apply on the next Harness creation, not to active sessions.

Project config cannot introduce or reconfigure runtime plugins. Supplying HarnessOptions.config is an explicit trust decision by the embedding application. Do not put secrets in project config or plugin metadata. Load them through your approved credentials integration/environment and never log them.

Capability interfaces

InterfaceContribution
ctx.tools.register(tool)Tool definition and implementation; existing permission pipeline still applies
ctx.models.register(provider, models)Model implementation plus its model definitions, validated atomically
ctx.auth.register(provider)Authentication implementation using the existing AuthProvider contract
ctx.hooks.on(event, handler)Existing typed lifecycle Hook, with automatic unsubscribe
ctx.audit.registerSink(sink)Best-effort permission/execution event observer
ctx.prompts.register(source)Async system-prompt section refreshed for each Agent turn
ctx.pages.register(page)Host-rendered page descriptor and read/action handlers
ctx.onStart(fn)Background work, started after all plugin setups finish
ctx.onDispose(fn)Resources to close, in reverse acquisition order

All contributions must be registered during setup. They are tracked by the host and automatically reversed. Default registration rejects conflicts; plugins cannot silently replace built-ins or each other's registrations. Existing fluent registry APIs remain compatible. Removing an auth implementation does not erase credentials. Model providers should contribute their own IDs, not attempt to overwrite existing catalog entries. Select a plugin model using the ordinary model config or model selection API.

ctx.signal announces plugin shutdown. Tools and model streams also receive the combined host/call cancellation signal. Keep background work cooperative; register resource cleanup immediately after acquisition, before later awaits that might fail. onStart must resolve after starting work, not await an infinite worker loop. Store its completion promise and await it in onDispose instead.

Audit plugin

The engine ships jsonlAuditPlugin (id: "audit-jsonl") using this same contract. Pass { path: "/absolute/path/to/events.jsonl" } as its config, either through the embedding API or a module entry pointing to packages/engine/dist/runtime-plugins/builtin/jsonl-audit.js. It creates parent directories, opens in append mode (0600 for a newly created file), and drains its queue before closing. It does not rotate files or change permissions on pre-existing files.

An audit sink receives RuntimeAuditEvent:

  • permission: existing PermissionEngine decision/answer records, including subagent decisions routed by the host.
  • tool_execution: completed calls, including thrown tool errors converted to error results; includes timing, tool identity and runtime plugin ownership.

Execution events omit input/output bodies and credentials. Permission records retain the existing rule/principal fields, which can contain paths or command arguments: sinks must handle their sensitivity. This is not a complete event replay or compliance journal; calls blocked before the PermissionEngine do not gain additional decision records in this implementation.

Each sink receives its own snapshot and ordered queue. A throwing sink does not block another sink or change a tool decision. Queues cap at 1,000 pending events per sink and warn/drop excess events. Shutdown drains the queues before closing sink resources, subject to the cleanup timeout. audit: false disables runtime audit events as well as the existing permission trail.

Example file sink setup (inside a plugin):

const file = await open(config.path, "a", 0o600); // node:fs/promises
ctx.onDispose(() => file.close());
ctx.audit.registerSink(async (event) => {
  await file.appendFile(JSON.stringify(event) + "\n");
});

This preserves the engine's existing best-effort audit semantics. Making an audit plugin required guarantees startup, not fail-closed writes. Mandatory durable audit would need a separately designed awaited execution policy.

Connector plugin

Use createConnectorPlugin as a convenience factory, or implement RuntimePlugin directly. The factory returns an ordinary RuntimePlugin, not another mechanism:

const plugin = createConnectorPlugin({
  id: "my-connector",
  version: "1.0.0",
  parseConfig: validateConnectorConfig,
  async connect(config, { signal, logger }) {
    // Wrap your selected SDK. Bind a trusted account here, not from model input.
    return createMyConnectorClient(config, { signal, logger });
  },
});

ConnectorClient exposes tools(): Promise<readonly Tool[]> and close(). If tool discovery fails, the connected client is still closed. The helper forces readOnly and sessionLocal off for external tools, so vendor metadata cannot silently bypass approval or plan-mode restrictions. Expose only approved tools, use namespaces, validate arguments and propagate each call's signal to the SDK. Do not send a whole vendor catalog into every model prompt.

The host does not implement vendor OAuth, account linking, webhooks or tenant storage. The [Cloud connector](cloud-connectors.md) supplies hosted-link creation, Web interaction and verified binding; it does not change the host protocol. Multi-user account identity must come from trusted application context, never from a model-selected account ID. No cloud infrastructure changes are required for the plugin host itself.

Lifecycle and compatibility

  1. Resolve enabled entries and validate the contract/configuration.
  2. Run setups in configured order; record registration and resource ownership.
  3. Run starts once all setups are complete; expose active status.
  4. On failure, roll back the failed plugin; abort and roll back all if required.
  5. On Harness close, prevent new host runs, cancel/drain existing runs, abort plugin calls, unregister contributions, then close resources in reverse order.

Cleanup is idempotent and continues after individual failures. Agent draining and each plugin drain/cleanup have a five-second bound. Timeouts are reported; they cannot forcibly stop uncooperative JavaScript, and work may remain after a timeout. A stale cloned plugin tool rejects calls after its plugin stops.

CLI /plugins and Web /context expose runtime state alongside declarative bundles. Errors omit plugin-provided exception messages to avoid leaking config secrets. Add sanitized diagnostics with the plugin-scoped logger if needed.

V1 intentionally has no live unload, cross-plugin dependency/service graph, remote package installer, sandbox, arbitrary frontend module loader, or replaceable Agent/Session core. Installed in-process plugins have the process's authority: the context API is a maintainability boundary, not a security sandbox.

See [configuration boundaries](configuration-files.md) to migrate legacy inline configuration. Separate plugin files are trusted user configuration, never loaded from an arbitrary project directory.