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.
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.
idjira-integration, slack-notifier.nameversion1.0.0, 0.3.1.authordescriptionproviderClassInstrumentProvider implementation.isolationModeInProcess, ClassLoaderIsolated, or External. Defaults to InProcess.dependenciesplatformsLinux, MacOS, Windows, All. Empty means all platforms.minHeftyVersionIsolation Modes
The isolation mode determines how much access your plugin has and how it communicates with the host.
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
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
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
) 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
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:
nameInstrument.name. Used for invocation.categoryShell, Filesystem, Process, Network, Application, Browser, InputCapture, Clipboard, System.descriptioninputSchemaoutputSchemasideEffects"writes files", "sends HTTP requests". Used for metadata validation.keywordsdefaultTimeoutMsmaxTimeoutMsaccessModeShared (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:
- Discovery - Hefty scans the
plugins/directory for JAR files containingMETA-INF/hefty-plugin.json - Dependency resolution - Plugins are sorted topologically by their dependencies. If a dependency is missing, the plugin is skipped with an error
- Version check - If
minHeftyVersionis set, it's compared against the running Hefty version - Loading - The provider class is instantiated and
initialize(context)is called - Instrument registration - Instruments are wrapped with the correct
Pluginorigin, validated, and indexed - Running - Instruments are available for invocation by the cognition pipeline
- 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 instrumentsplugin_install- Load a plugin from a JAR pathplugin_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
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
defaultTimeoutMsandmaxTimeoutMsvalues. The host may enforce lower timeouts than your maximum. - Use the plugin logger - Always use
ctx.loggerinstead ofprintlnor 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
InstrumentErrorwith 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
Exclusiveonly 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.