Skip to content

Plugin Development

ZERG plugins extend the Sol server with custom behavior using a sandboxed Lua runtime. This guide covers the plugin API, bridge modules, lifecycle hooks, and practical examples.

Architecture

Plugins run inside a Luerl Lua VM sandbox within the Sol Erlang process. The plugin manager (sol_plugin_manager) loads plugins at startup, manages their lifecycle, and dispatches events to them.

Sol Server
  |
  +-- sol_plugin_manager (gen_server)
        |
        +-- Plugin A (Luerl VM)
        |     +-- sol.log
        |     +-- sol.http
        |     +-- sol.config
        |     +-- sol.events
        |     +-- sol.channel
        |     +-- sol.json
        |     +-- sol.time
        |
        +-- Plugin B (Luerl VM)
              +-- ...

Plugin Sandbox

The Luerl VM is stripped of dangerous standard libraries:

LibraryStatus
osRemoved
ioRemoved
debugRemoved
packageRemoved
stringAvailable
tableAvailable
mathAvailable
coroutineAvailable

Additional protections:

  • Eval timeout: 15 seconds per lifecycle call
  • Periodic garbage collection between calls
  • No filesystem access
  • No direct network access (must use sol.http bridge)
  • No process spawning

Bridge Modules

Plugins access Sol functionality through 15 bridge modules exposed in the sol table:

sol.log

Logging bridge. Outputs to the Erlang logger:

lua
sol.log.info("processing started")
sol.log.warning("high memory usage detected")
sol.log.error("failed to process item")

sol.http

HTTP client bridge. Makes outbound requests without exposing the network layer:

lua
local response = sol.http.get("https://api.example.com/status")
local data = sol.http.post("https://api.example.com/webhook", payload)

Requests are pooled and managed by sol_plugin_http_pool_owner.

sol.config

Configuration bridge. Read plugin-specific configuration:

lua
local api_key = sol.config.get("api_key")
local timeout = sol.config.get("timeout") or 30

Configuration is loaded from the plugin's entry in the plugins config file.

sol.events

Event subscription bridge. Listen to Sol system events:

lua
sol.events.subscribe("task_completed", function(event)
    sol.log.info("task " .. event.task_id .. " completed")
end)

sol.channel

Channel messaging bridge. Send messages to conversation channels:

lua
sol.channel.send(channel_id, "Processing complete")

Used by format_and_send to deliver formatted event notifications.

sol.json

JSON bridge. Encode and decode JSON data:

lua
local data = sol.json.decode('{"key": "value"}')
local encoded = sol.json.encode({result = "ok"})

sol.time

Time bridge. Access current time without the os library:

lua
local now = sol.time.now()
local formatted = sol.time.format(now)

sol.tools

Tool registration bridge. Register custom tools that workers can invoke:

lua
sol.tools.register("my_tool", {
    description = "Does something useful",
    handler = function(args)
        return {result = "ok"}
    end
})

sol.commands

Command registration bridge. Register slash commands for the TUI:

lua
sol.commands.register("/mycommand", function(args)
    sol.log.info("command executed: " .. args)
end)

sol.sessions

Session access bridge. Read and search session data:

lua
local session = sol.sessions.get(session_id)
local results = sol.sessions.search("query")

sol.providers

Provider configuration bridge. List and query available AI providers:

lua
local providers = sol.providers.list()
local model_info = sol.providers.get_model("glm-5.1")

sol.workflows

Workflow control bridge. Start, monitor, and cancel workflows:

lua
local wf_id = sol.workflows.start("my_workflow", {input = "data"})
sol.workflows.cancel(wf_id)

sol.agents

Agent management bridge. Spawn and manage agent instances:

lua
local agent_id = sol.agents.spawn({model = "glm-5.1", mode = "build"})
sol.agents.send_message(agent_id, "Hello")

sol.memory

Memory storage bridge. Persistent KV store with TTL:

lua
sol.memory.store("key", "value", {ttl = 3600})
local val = sol.memory.get("key")
sol.memory.delete("key")

sol.scheduler

Job scheduling bridge. Create and manage scheduled jobs:

lua
local job_id = sol.scheduler.create("cron", "*/5 * * * *", {task = "check_health"})
sol.scheduler.cancel(job_id)

Plugin Lifecycle

Plugins implement optional lifecycle functions that the plugin manager calls at specific points:

init

Called once when the plugin is loaded. Use for setup and configuration:

lua
function init()
    sol.log.info("my plugin initialized")
    return true
end

start

Called after all plugins are loaded. Use for starting background operations:

lua
function start()
    sol.log.info("my plugin started")
    sol.events.subscribe("task_completed", on_task_complete)
    return true
end

stop

Called when the plugin is unloaded or the server shuts down. Clean up resources:

lua
function stop()
    sol.log.info("my plugin stopping")
    return true
end

poll

Called periodically for polling plugins. Register with start_polling:

lua
function poll(interval_ms)
    local status = sol.http.get("https://monitor.example.com/health")
    if status ~= "ok" then
        sol.channel.send("alerts", "Service degraded: " .. status)
    end
    return true
end

format_event

Called when the plugin is asked to format an event for a channel:

lua
function format_event(event_type, event_data)
    if event_type == "task_completed" then
        return "Task " .. event_data.task_id .. " finished successfully"
    end
    return nil
end

Plugin Configuration

Plugins are configured in the Sol configuration. Each plugin has a name, type, version, and config map:

erlang
{plugins, [
    #{name => <<"monitor">>,
      type => <<"poller">>,
      version => <<"1.0.0">>,
      config => #{endpoint => <<"https://monitor.example.com">>,
                  interval => 60000}}
]}

Loading and Unloading

Via API

bash
curl -X POST http://127.0.0.1:21434/api/v1/plugins/load \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-plugin", "config": {"key": "value"}}'

List Loaded Plugins

bash
curl -s http://127.0.0.1:21434/api/v1/plugins | jq .

Unload a Plugin

bash
curl -X POST http://127.0.0.1:21434/api/v1/plugins/my-plugin/unload \
  -H "Authorization: Bearer $TOKEN"

Reload All Plugins

bash
curl -X POST http://127.0.0.1:21434/api/v1/plugins/reload \
  -H "Authorization: Bearer $TOKEN"

Complete Example

A notification plugin that sends alerts on task failures:

lua
function init()
    webhook_url = sol.config.get("webhook_url") or ""
    sol.log.info("alert plugin initialized")
    return true
end

function start()
    sol.events.subscribe("task_failed", on_task_failed)
    sol.log.info("alert plugin watching for failures")
    return true
end

function on_task_failed(event)
    local payload = sol.json.encode({
        task_id = event.task_id,
        error = event.error,
        timestamp = sol.time.now()
    })
    sol.http.post(webhook_url, payload)
    sol.log.warning("alert sent for task " .. event.task_id)
end

function stop()
    sol.log.info("alert plugin stopped")
    return true
end

Save as priv/plugins/alert.lua and configure:

erlang
{plugins, [
    #{name => <<"alert">>,
      type => <<"responder">>,
      version => <<"1.0.0">>,
      config => #{webhook_url => <<"https://hooks.example.com/zerg">>}}
]}

Plugin Directories

Plugins are loaded from the directory configured by SOL_PLUGIN_DIR (default: priv/plugins). Each plugin is a single .lua file. The plugin manager scans this directory at startup.

Error Handling

Plugin errors do not crash the Sol server. The plugin manager catches all exceptions and logs them. If a plugin's lifecycle function fails, the plugin is marked as errored but other plugins continue operating.

The eval timeout (15 seconds) prevents runaway plugins from blocking the system. Periodic GC runs between lifecycle calls to reclaim memory from the Luerl VM.

Released under the MIT License.