Writing a Trimus plugin¶
A plugin is a separate OS process that Trimus starts and supervises inside a Docker sandbox. It communicates with the host over JSON-lines (newline-delimited JSON) on stdin/stdout. A plugin can:
- Contribute tools that agents (and API callers) invoke.
- Receive webhooks routed to plugin-defined logic.
- Run scheduled jobs on a cron schedule.
- Subscribe to events and react to them.
Use a plugin when a routine + agent isn't enough — typically when you need stateful logic, a long-lived connection to a third party, or a tool agents should call as part of their normal flow.
This page is the integrator's tour. The canonical, exhaustive
reference — every message type, full Go and non-Go examples, the API
surface, troubleshooting — is PLUGIN.md. The
protocol types live in
internal/plugins/protocol.go.
When to write one¶
| Want to… | Use instead? |
|---|---|
| React to one-off webhook events | A routine trigger (webhooks-in.md) |
| Pull data in on a schedule | A scheduled routine |
| Watch live events and react from outside Trimus | An SSE consumer (sse-streaming.md) |
| Give agents a callable tool with real logic | A plugin |
| Bridge a system that needs persistent state / OAuth | A plugin |
A plugin adds a process to operate and a binary to deploy — set the bar high. If a routine + a small relay does the job, do that first.
The model: JSON-lines over a sandboxed subprocess¶
There is no Go SDK package to import — a plugin is any executable that speaks the protocol on stdin/stdout. Every message is one line of JSON:
Max message size is 1 MB. The host drives the conversation; your plugin responds.
Host → plugin messages¶
| Type | Meaning | You respond with |
|---|---|---|
init |
Sent first. Carries plugin_id, company_id, config, and your host_capabilities allowlist. |
ready |
health_check |
Liveness probe. | (prompt response; no payload required) |
invoke_tool |
Run a tool: {tool, input, request_id}. |
tool_result (same request_id) |
trigger_event |
An event you subscribed to fired: {event_type, data}. |
event_handled |
run_job |
A scheduled job is due: {job_name}. |
job_result |
deliver_webhook |
An inbound webhook for one of your paths: {path, headers, body}. |
(handle it) |
shutdown |
Graceful stop. | Exit cleanly. |
Plugin → host messages¶
| Type | Meaning |
|---|---|
ready |
Sent once after init, carrying your manifest (see below). |
tool_result |
{request_id, result, error} — the answer to invoke_tool. |
event_handled / job_result |
Acknowledge an event / job. |
host_request |
Ask the host for a service: {service, args, request_id} (see Host services). |
log |
{level, message} — emit a structured log line via the host. |
error |
{message, code} — report an error. |
The manifest¶
On init, your plugin replies with ready carrying a manifest that
declares everything it contributes:
{
"type": "ready",
"payload": {
"manifest": {
"name": "github",
"tools": [
{
"name": "list-repos",
"namespace": "github",
"description": "List repositories for an org",
"input_schema": { "type": "object", "properties": { "org": {"type":"string"} } }
}
],
"events": ["issue.created", "issue.*"],
"jobs": [{ "name": "sync-users", "schedule": "0 */6 * * *" }],
"webhooks": [{ "path": "github/push" }]
}
}
}
Tools get a qualified name namespace:name (typically
slug:tool — e.g. github:list-repos) so tools from different plugins
don't collide.
Registering a plugin¶
Register the plugin record in the database, then start it:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name":"GitHub Integration",
"slug":"github",
"description":"Repo + PR tools for agents",
"version":"1.0.0",
"binary_path":"/usr/local/bin/trimus-plugin-github",
"binary_hash":""
}' \
"$BASE/api/companies/$COMPANY/plugins"
Fields (from CreatePlugin): name and slug are required;
description, version, binary_path, binary_hash, and config
(JSON passed to the plugin on init) are optional. The caller must be a
company admin. The binary_path must be readable + executable by the
OS user running trimus-server (or, with the sandbox on, mountable into the
container).
Approval gate — a plugin can't run until an admin allows it¶
A registered plugin starts in approval_state = pending and will not
start until an admin allows it. The approval state machine
(internal/handler/plugin_approval.go, enforced in
internal/plugins/host.go):
| Transition | Endpoint | Effect |
|---|---|---|
| Allow | POST /api/plugins/{id}/allow |
State → allowed; snapshots the binary's SHA-256 as approved_binary_hash. |
| Ban | POST /api/plugins/{id}/ban |
State → banned; refuses to start. |
| Rescind ban | POST /api/plugins/{id}/rescind-ban |
Lifts a ban (back toward pending). |
On every StartPlugin, the host:
- Refuses if state is
pendingorbanned. - Re-hashes the on-disk binary and compares it to
approved_binary_hash. A mismatch auto-reverts the plugin topending, writes an audit row, and refuses to start — so a binary swapped on disk after approval can't run silently. Re-approve to re-enable.
The allow/ban endpoints self-gate on admin role inside the handler
(not via route middleware) so a denied attempt still writes an audit row.
All transitions are visible at GET /api/plugins/{id} and the company's
plugin audit log.
Starting, stopping, restarting¶
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/start"
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/stop"
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/restart"
These are admin-only. Operators can also start/stop/restart from the
Plugins page. The lifecycle state (installed → ready → running
→ stopped/errored) is tracked separately from the approval state and
recorded for auditing.
The Docker sandbox¶
By default, each plugin runs inside a hardened Docker container (source:
internal/plugins/sandbox.go).
Your plugin gets:
--read-onlyroot filesystem with a small--tmpfs /tmp.--network none— no network from the container itself. A plugin that needs to call out does so through the host'shttp_fetchservice (below), not by opening sockets.--security-opt no-new-privileges.- Resource caps:
--memory 256m(+ matching--memory-swap),--cpus 0.5,--pids-limit 100. - A read-only bind mount of the plugin binary and a read-write
/workspacefor scratch.
Defaults are overridable via env (TRIMUS_PLUGIN_SANDBOX_IMAGE,
TRIMUS_PLUGIN_MEMORY_LIMIT, TRIMUS_PLUGIN_CPU_LIMIT,
TRIMUS_PLUGIN_PIDS_LIMIT, …). Operators can opt out entirely with
TRIMUS_PLUGIN_SANDBOX=disabled (plugins then run as bare host
subprocesses — a logged security downgrade). If the Docker CLI isn't on
PATH, the host falls back to bare subprocess with a one-shot warning.
Tool invocation¶
Because the container has no network and there is no /plugin-tool action
verb, tools are invoked over the host API, which routes the
invoke_tool message to your process and waits for the matching
tool_result:
# Plugin-scoped (you know which plugin) — admin:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"tool":"list-repos","input":{"org":"acme"}}' \
"$BASE/api/plugins/$PLUGIN_ID/tools/invoke"
# Global (resolve by qualified name) — member+:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"tool":"github:list-repos","input":{"org":"acme"}}' \
"$BASE/api/companies/$COMPANY/plugins/tools/invoke"
Invocation is synchronous from the caller's perspective — the host blocks
on the tool_result (respecting the request timeout).
Host services — the capability allowlist¶
Inside the sandbox your plugin can't reach the network or the host
directly. It asks the host for a service via a host_request message,
gated by a per-plugin host_capabilities allowlist
(internal/plugins/host_services.go).
The full surface is exactly two services:
| Service | What it does | Notes |
|---|---|---|
http_fetch |
Outbound HTTP {url, method, headers, body} → {status, headers, body, truncated}. |
SSRF-protected: blocks RFC-1918, loopback, link-local, and cloud-metadata IPs. Body capped at 10 MB (truncated:true when clipped). |
log |
Append a structured log line {level, message}. |
Read-only effect. |
The default allowlist is ["http_fetch","log"]. An admin tightens it by
editing the plugin's host_capabilities array (set to [] for a
read-only plugin). A host_request for a service not in the allowlist
returns a structured error and is logged. The allowlist is sent to your
plugin on init (the host_capabilities field) so you can condition tool
behaviour up front.
Webhooks, jobs, and events¶
- Webhooks: declare a path in the manifest; Trimus delivers POSTs to
/api/companies/{companyId}/plugins/webhooks/{path}as adeliver_webhookmessage (path, headers, body). See webhooks-in.md. - Jobs: declare
{name, schedule}(5-field cron via robfig/cron); the host sendsrun_jobat each tick. - Events: declare event patterns (
issue.created,issue.*,*); the host sendstrigger_eventfor matches. Events are company-scoped — a plugin in company A never sees company B's events.
What a plugin can and can't do¶
It can contribute tools, take webhooks, run jobs, react to events, fetch HTTP through the host (SSRF-guarded), and log.
It cannot:
- Take down trimus-server — it's an isolated, resource-capped subprocess.
- Reach the network directly — the container is
--network none; usehttp_fetch. - Reach a host service outside its
host_capabilitiesallowlist. - Run before an admin allows it, or run a binary that differs from the approved hash.
Where to next¶
- PLUGIN.md — the full protocol, worked Go + non-Go examples, the complete API surface, and troubleshooting.
- webhooks-in.md — the simpler "trigger → routine" alternative.
- api-recipes.md — the API your plugin's webhook/job logic will call.
- patterns.md — worked plugin-based integration examples.