Plugin Development

Plugins let you extend Hefty with new instruments packaged as standalone JARs. A plugin provides one or more instruments that integrate seamlessly with Hefty's cognition pipeline, security model, and UI. Once installed, plugin instruments are indistinguishable from built-in ones.

Prerequisites

You'll need Scala 3.3+ or Java 21+ and a build tool (sbt, Gradle, or Maven). Plugins depend on Hefty's instrument API types, which ship inside the hefty-core JAR.

Quick Start

A minimal plugin has three pieces: a manifest, a provider class, and one or more instrument implementations.

1. Create the manifest

Place a file at META-INF/hefty-plugin.json inside your JAR:

{
  "id": "hello-world",
  "name": "Hello World Plugin",
  "version": "1.0.0",
  "author": "Your Name",
  "description": "A minimal example plugin",
  "providerClass": "com.example.HelloWorldProvider",
  "isolationMode": "InProcess",
  "dependencies": [],
  "platforms": [],
  "minHeftyVersion": "0.22.0"
}

2. Implement the provider

Your provider class is the entry point. It implements InstrumentProvider, receives a PluginContext at initialization, and returns a list of instruments:

package com.example

import hefty.core.instrument._
import hefty.core.instrument.plugin._
import io.circe.Json
import scala.concurrent.{ExecutionContext, Future}

class HelloWorldProvider extends InstrumentProvider:
  private var ctx: PluginContext = _

  def initialize(context: PluginContext): Unit =
    ctx = context
    ctx.logger.info("Hello World plugin initialized")

  def instruments: List[Instrument] = List(HelloInstrument(ctx))

  def shutdown(): Unit =
    ctx.logger.info("Hello World plugin shutting down")

3. Implement an instrument

package com.example

import hefty.core.instrument._
import hefty.core.instrument.plugin.PluginContext
import io.circe.Json
import scala.concurrent.{ExecutionContext, Future}

class HelloInstrument(ctx: PluginContext) extends Instrument:
  val name = "hello_world"
  val origin = InstrumentOrigin.Base // Will be overridden to Plugin by the host
  val riskLevel = RiskLevel.ReadOnly

  val metadata = InstrumentMetadata(
    name = "hello_world",
    category = InstrumentCategory.Application,
    description = "Returns a greeting. A minimal plugin instrument example.",
    accessMode = AccessMode.Shared,
    platform = Platform.All,
    inputSchema = Json.obj(
      "type" -> Json.fromString("object"),
      "properties" -> Json.obj(
        "name" -> Json.obj("type" -> Json.fromString("string"),
                           "description" -> Json.fromString("Who to greet"))
      )
    ),
    outputSchema = Json.obj(),
    sideEffects = List.empty,
    defaultTimeoutMs = 5000,
    maxTimeoutMs = 10000,
    keywords = List("hello", "greet", "example")
  )

  def invoke(params: Json, ctx: InvocationContext)(using ExecutionContext): Future[InstrumentResult] =
    val name = params.hcursor.downField("name").as[String].getOrElse("World")
    Future.successful(
      InstrumentResult.stdio(stdout = s"Hello, $name!", stderr = "", status = InstrumentResultStatus.Success)
    )

  def healthCheck()(using ExecutionContext): Future[InstrumentHealth] =
    Future.successful(InstrumentHealth.Healthy)

4. Build and install

# Build your plugin JAR (e.g. with sbt)
sbt package

# Copy to Hefty's plugin directory
cp target/scala-3.3.4/hello-world_3-1.0.0.jar ~/.hefty/plugins/

Hefty loads plugins from its plugins/ directory on startup. You can also install at runtime via the admin tools.

Plugin Manifest

The manifest tells Hefty everything it needs to know about your plugin before loading any code. It must be at META-INF/hefty-plugin.json inside the JAR.

id
Required. Unique identifier. Use lowercase with hyphens: jira-integration, slack-notifier.
name
Required. Human-readable display name.
version
Required. Semver string: 1.0.0, 0.3.1.
author
Required. Plugin author name or organization.
description
Required. Short description of what the plugin does.
providerClass
Required. Fully qualified class name of your InstrumentProvider implementation.
isolationMode
How the plugin runs. One of InProcess, ClassLoaderIsolated, or External. Defaults to InProcess.
dependencies
List of plugin IDs this plugin requires. Hefty loads dependencies first via topological sort.
platforms
List of supported platforms: Linux, MacOS, Windows, All. Empty means all platforms.
minHeftyVersion
Minimum Hefty version required. The plugin won't load on older versions.

Isolation Modes

The isolation mode determines how much access your plugin has and how it communicates with the host.

InProcess
Trusted

Runs in the same JVM with a shared classloader. Full access to the host's classpath. Best for first-party plugins or tightly integrated extensions.

  • Fastest - direct method calls
  • Full access to host libraries
  • Can crash the host if buggy
ClassLoaderIsolated
Isolated Classes

Runs in the same JVM but with a separate classloader. Only Hefty's instrument API, Circe, Scala stdlib, and SLF4J are visible. Use this when your plugin bundles its own dependencies that might conflict with the host.

  • Prevents dependency conflicts
  • Still shares the JVM - not a security boundary
  • Direct method calls, no serialization overhead
External
Separate Process

Runs in a separate JVM process. Communication happens over stdin/stdout using JSON-RPC 2.0. This is the only mode that provides a real security boundary.

  • Process-level isolation - cannot crash the host
  • Can be resource-limited by the OS
  • Higher latency (JSON serialization + IPC)
  • Cognition and embedding APIs not available

Plugin Context

When your plugin initializes, it receives a PluginContext that provides access to host services:

trait PluginContext:
  def pluginId: String                   // Your plugin's ID
  def dataDir: Path                      // Persistent data directory (created for you)
  def config: Map[String, String]        // Configuration from hefty.conf
  def heftyVersion: String               // Current Hefty version
  def logger: org.slf4j.Logger           // Logger scoped to your plugin

  // Host services
  def requestCognition(prompt: String, maxTokens: Int): Future[String]
  def requestEmbedding(text: String): Future[Array[Float]]

Configuration

Plugin-specific configuration lives in Hefty's application.conf under the hefty.plugins namespace. Users add settings using your plugin ID as the key:

hefty {
  plugins {
    jira-integration {
      base-url = "https://mycompany.atlassian.net"
      api-token = "your-token-here"
      default-project = "ENG"
    }
  }
}

These appear in your PluginContext.config as a flat Map[String, String]:

def initialize(context: PluginContext): Unit =
  val baseUrl = context.config.getOrElse("base-url", "")
  val apiToken = context.config.getOrElse("api-token", "")
  if baseUrl.isEmpty || apiToken.isEmpty then
    throw IllegalStateException("jira-integration requires base-url and api-token")

Data Directory

Each plugin gets a persistent directory at plugins/<pluginId>/ inside Hefty's data directory. Hefty creates this directory for you before calling initialize(). Use it for caches, databases, or any state that should survive restarts.

Cognition and Embeddings

Plugins can use Hefty's LLM and embedding services. This lets instruments perform AI-powered operations without managing their own model connections:

def invoke(params: Json, invCtx: InvocationContext)(using ExecutionContext): Future[InstrumentResult] =
  val text = params.hcursor.downField("text").as[String].getOrElse("")
  for
    summary <- ctx.requestCognition(s"Summarize in one paragraph:\n\n$text", 500)
    embedding <- ctx.requestEmbedding(summary)
  yield
    InstrumentResult.stdio(
      stdout = summary,
      stderr = "",
      status = InstrumentResultStatus.Success
    )
External mode limitation

Cognition and embedding APIs are not available in External isolation mode. Calls will fail with an UnsupportedOperationException.

The Instrument Trait

Every instrument implements the Instrument trait. Here's what each field means and how the host uses it:

trait Instrument:
  def name: String                       // Unique identifier (snake_case)
  def origin: InstrumentOrigin           // Set to Base - host overrides to Plugin(id)
  def riskLevel: RiskLevel               // Highest risk of any operation
  def metadata: InstrumentMetadata       // Description, schema, timeouts
  def securityRules: List[SecurityRule]   // Optional per-instrument security rules

  def invoke(params: Json, ctx: InvocationContext)(using ExecutionContext): Future[InstrumentResult]
  def healthCheck()(using ExecutionContext): Future[InstrumentHealth]

Risk Levels

Declare the highest risk level that applies to your instrument. Hefty uses this for access control and UI indicators:

  • ReadOnly - reads data, no mutations
  • LocalWrite - writes to local filesystem
  • SystemExecute - executes commands or processes
  • NetworkWrite - makes external network requests
  • Critical - destructive or irreversible operations
Origin override

You can set origin to any value - the host will override it to InstrumentOrigin.Plugin(yourPluginId) after loading. This ensures instruments are always correctly attributed.

Metadata

InstrumentMetadata describes your instrument to the cognition pipeline and UI:

name
Must match Instrument.name. Used for invocation.
category
One of: Shell, Filesystem, Process, Network, Application, Browser, InputCapture, Clipboard, System.
description
What the instrument does. Shown in the UI and used by the cognition pipeline to decide when to invoke it.
inputSchema
JSON Schema describing the parameters your instrument accepts.
outputSchema
JSON Schema describing the result format.
sideEffects
List of side effect descriptions: "writes files", "sends HTTP requests". Used for metadata validation.
keywords
Search keywords that help the cognition pipeline find your instrument.
defaultTimeoutMs
Default execution timeout in milliseconds.
maxTimeoutMs
Maximum timeout allowed (host may cap it further).
accessMode
Shared (concurrent access) or Exclusive (one invocation at a time).

Invocation Context

Every invoke() call receives an InvocationContext with runtime information:

case class InvocationContext(
  activityId: String,        // Current activity/turn ID
  sessionId: String,         // Current session ID
  traceId: Option[String],   // Distributed tracing ID
  timeoutMs: Option[Long],   // Override timeout for this call
  dataDirPath: Option[String] // Path to agent data directory
)

Returning Results

Use InstrumentResult.stdio() to return results in the standard format:

// Success
InstrumentResult.stdio(
  stdout = "Operation completed: 42 items processed",
  stderr = "",
  status = InstrumentResultStatus.Success
)

// Error
InstrumentResult.stdio(
  stdout = "",
  stderr = "Connection refused",
  status = InstrumentResultStatus.Error,
  error = Some(InstrumentError("CONNECTION_ERROR", "Could not reach API", None))
)

Security

Hefty's security model applies to plugin instruments the same way it applies to built-in ones. Understanding these mechanisms helps you write plugins that work correctly within the security boundaries.

Name Collisions

Instrument names must be unique across all plugins. If your plugin defines an instrument with the same name as one in another plugin, loading will fail with an InstrumentNameCollision error. If your instrument name matches a core instrument, the core version takes precedence and a warning is logged.

Origin-Aware Access Control

Administrators can restrict which instrument origins are available per role. By default, all origins are allowed, but a deployment may configure:

hefty.instruments.access {
  user-allowed-origins = ["base", "built_in"]     # No plugin instruments for regular users
  guest-allowed-origins = ["base"]                 # Guests get base instruments only
}

Plugin instruments are tagged with origin plugin. If your plugin instruments should be available to restricted roles, the administrator must add "plugin" to the allowed origins.

Security Rules

You can attach per-instrument security rules by overriding securityRules. These are merged with the global security policy during validation:

override def securityRules: List[SecurityRule] = List(
  // Define rules that apply specifically to this instrument
)

Metadata Validation

Hefty validates plugin metadata at load time. If your instrument declares a Network or Shell category but has an empty sideEffects list, a warning is logged. This helps catch misconfigurations where the risk level may be understated.

Lifecycle

Understanding the plugin lifecycle helps you manage resources correctly:

  1. Discovery - Hefty scans the plugins/ directory for JAR files containing META-INF/hefty-plugin.json
  2. Dependency resolution - Plugins are sorted topologically by their dependencies. If a dependency is missing, the plugin is skipped with an error
  3. Version check - If minHeftyVersion is set, it's compared against the running Hefty version
  4. Loading - The provider class is instantiated and initialize(context) is called
  5. Instrument registration - Instruments are wrapped with the correct Plugin origin, validated, and indexed
  6. Running - Instruments are available for invocation by the cognition pipeline
  7. Shutdown - On unload or Hefty shutdown, shutdown() is called on the provider

Runtime Management

Plugins can be managed at runtime through Hefty's admin tools:

  • plugins_list - List all loaded plugins and their instruments
  • plugin_install - Load a plugin from a JAR path
  • plugin_unload - Unload a plugin and remove its instruments

External Plugin Protocol

Plugins using External isolation communicate via JSON-RPC 2.0 over stdin/stdout. Hefty spawns the plugin as a child process and sends requests as newline-delimited JSON:

Message Flow

Host Plugin Process | | |── initialize ────────────────>| (pluginId, dataDir, config, heftyVersion) |<──────────── {success:true} ──| | | |── listInstruments ───────────>| |<──── {instruments: [...]} ────| | | |── invoke ────────────────────>| (instrument, params, activityId, ...) |<──── {status, data, error} ───| | | |── healthCheck ───────────────>| (instrument) |<──── {status, message} ──────| | | |── shutdown ──────────────────>| |<──────────── {success:true} ──| | X

The host provides an ExternalPluginHost main class that handles the protocol for you. Your plugin JAR just needs to implement InstrumentProvider as usual - the host class takes care of serialization and routing.

Best Practices

  • Use descriptive names and keywords - The cognition pipeline uses your instrument's description and keywords to decide when to use it. Be specific about what it does and when it's useful.
  • Declare side effects honestly - If your instrument writes files or makes network calls, say so. Understating side effects can lead to unexpected behavior under restricted security policies.
  • Handle timeouts gracefully - Set realistic defaultTimeoutMs and maxTimeoutMs values. The host may enforce lower timeouts than your maximum.
  • Use the plugin logger - Always use ctx.logger instead of println or your own logger. This ensures logs are scoped to your plugin ID and appear in the right place.
  • Clean up in shutdown - Close connections, flush caches, and release resources in your shutdown() method.
  • Return meaningful errors - Use InstrumentError with descriptive codes and messages. The cognition pipeline uses error information to decide whether to retry or try a different approach.
  • Prefer Shared access mode - Use Exclusive only if your instrument truly cannot handle concurrent invocations (e.g., it manages a single serial port).
  • Validate inputs early - Check required parameters at the start of invoke() and return a clear error immediately rather than failing deep in your logic.