Out-of-process plugins
Apiary protocol 1 runs third-party extensions as child processes instead of loading Go binaries. A plugin crash, invalid response, or timeout terminates only that invocation; it does not crash the dispatcher. Plugins are never executed during discovery or configuration validation.
Architecture
A plugin is an executable file (any language) installed in a directory together with a manifest. The daemon never links against it and never keeps it running: every call spawns a fresh process, writes one JSON request to its stdin, reads one JSON response from its stdout, and the process exits.
flowchart LR
subgraph daemon [apiary daemon]
DISC[Discovery<br/>plugin_dirs → registry] --> VAL[Validation<br/>manifest + config schema]
VAL --> CLIENT[Plugin client<br/>one per enabled instance]
SRC["type: plugin source<br/>(poll interval)"] --> CLIENT
EVT[execution events] --> CLIENT
end
CLIENT -- "spawn per call<br/>JSON on stdin" --> PROC[plugin process]
PROC -- "one JSON response<br/>on stdout, then exit" --> CLIENT
PROC --> BACKEND[(backing system:<br/>API, file, queue…)]
The moving parts, in the order they run:
- Discovery scans
plugin_dirsfor directories containingapiary-plugin.json, builds a registry, and rejects duplicate IDs. No plugin code executes during discovery. - Validation (
apiary validate,apiary plugins validate, daemon startup) checks the manifest — schema version, semver compatibility with the host, protocol version, executable safety, checksum pin — and validates each enabled instance'sconfigagainst the manifest's JSON Schema. Still no plugin code executes. - Client creation happens at daemon startup for every enabled instance whose manifest declares a capability the daemon integrates (see the capability table). The client re-verifies the executable's checksum pin here and before every later invocation.
- Invocation is where plugin code finally runs — see the lifecycle below.
Invocation lifecycle
Each call is single-shot and stateless:
- The client re-checks the pinned checksum (cheap
stat()unless the file changed) and spawns the executable with a deadline (the instance'stimeout, default 10s). - One compact JSON request — protocol version, request ID, capability,
method, the instance's
config, and the call payload — is written to the child's stdin, followed by a newline. - The plugin does its work (call an API, read a file …) and writes exactly one JSON response to stdout, echoing the request ID. Diagnostics belong on stderr.
- The client enforces the contract: protocol version match, request-ID echo, a single response object, stdout ≤ 4 MiB, stderr capture ≤ 64 KiB. The deadline kills the process; a crash, timeout, or malformed response fails only that invocation.
Because every call is a fresh process, plugins hold no in-memory state between calls. State lives in the backing system the plugin fronts (or a file it manages). This is what makes the isolation story simple — there is no long-running sidecar to supervise, leak, or restart.
Process environment
| Aspect | Behavior |
|---|---|
| Working directory | The plugin's install directory, not the project root — relative paths in plugin config resolve there, so prefer absolute paths |
| Environment | Minimal allowlist (PATH, HOME, TMPDIR, locale/timezone) plus only the variables named in the manifest's security.secret_env — daemon secrets never leak in by default |
| Identity | APIARY_PLUGIN_ID and APIARY_PLUGIN_PROTOCOL are always set |
| Configuration | Passed inside every request (config field) — never via environment or files |
How capabilities integrate
| Capability | When the daemon invokes it |
|---|---|
source |
A sources[] entry with type: plugin bridges to the instance; the daemon calls poll on the source's poll_interval and forwards acknowledge/write_result after dispatch. Polling only — plugins never push into the daemon |
event_exporter |
Once per persisted (redacted) execution event |
runner, workflow_action, approval_provider, secret_provider |
Reserved: manifest and transport are stable, daemon integration not yet wired |
Failure semantics follow the integration point: a failed poll is logged and
retried on the next interval like any source poll error; a failed export is
logged and dropped. A plugin can never take the dispatcher down.
Where plugins live
An installed plugin is one directory per plugin, named by its id, holding exactly two things: the manifest and the executable it names.
<project>/
├── apiary.yaml
├── .env
└── .apiary/
└── plugins/ ← default search directory
├── dev.apiary.source-file/ ← one directory per plugin id
│ ├── apiary-plugin.json ← manifest
│ └── apiary-plugin-source-file ← executable (chmod +x)
└── com.example.nagios/
├── apiary-plugin.json
└── nagios-source
The default search directory is .apiary/plugins beside apiary.yaml.
plugin_dirs replaces it; list several to layer scopes:
plugin_dirs:
- .apiary/plugins # project-local: plugins for this project only
- ~/.local/share/apiary/plugins # user-global: shared across your projects
# - /opt/apiary/plugins # system-wide: daemon-as-a-service installs
Relative entries resolve beside apiary.yaml. Discovery scans every listed
directory; a plugin id appearing in more than one is an error — Apiary never
picks a winner by path order. Keep the directories writable only by the
Apiary operator or service account: plugins run with the daemon's OS
permissions.
Installing a plugin
Installation is placing files, deliberately — apiary plugins install does that
for you and verifies what it places, or you do it by hand. Either way the daemon
never downloads anything, nothing is enabled without an edit you make, and the
files land in the same place.
From the registry
apiary plugins search cron # find one
apiary plugins info dev.apiary.routines # read what it declares
apiary plugins install dev.apiary.routines # verify, confirm, place the files
install resolves the release for this host — version constraint, protocol,
platform, withdrawals — before downloading, then verifies the archive's
digest against the registry's, unpacks it into a staging directory outside every
searched path, validates the manifest, confirms the archive holds the plugin you
asked for, and verifies the executable's digest. Only then does it print what it
is about to install and ask:
dev.apiary.routines 0.1.0 (source)
from https://github.com/orlandoburli/apiary-routines/releases/download/v0.1.0/…tar.gz
sha256 efb15a92…405f64d7 (verified)
registry https://orlandoburli.com.br/apiary/registry/v1/index.json (signature verified)
conformance FAILED the protocol kit in registry CI — expect protocol bugs
pinned f4d0c199…52b709f6 (from the registry, not from the archive)
Declared access (a declaration, not a sandbox):
network no
read paths configured state_file
write paths configured state_file
secret env (none)
This executable will run with the daemon's OS permissions, as its user.
A registry listing is a pointer to someone else's repository — it is not an
endorsement, and Apiary has not reviewed this code.
Install into .apiary/plugins? [y/N]
That conformance line is real, and worth reading rather than skimming: registry CI ran the conformance kit against this published binary and it failed three cases. A failure does not block a listing or an install — the registry describes plugins, it does not certify them — but it does tell you to expect protocol-level rough edges.
--yes skips the prompt, not the summary. Other flags: --dir to choose which
searched directory to install into (default: the first plugin_dirs entry),
--sha256 to cross-check the archive digest you were told to expect against the
registry's (a disagreement stops the install before anything is downloaded),
--registry for a one-off index, --offline to use the cached index.
The commit is a single atomic rename: until you answer the prompt, nothing exists in any directory the daemon searches.
Pinning. If the publisher's manifest carries no checksum, the installer
writes the registry's executable digest into it. This is the point of installing
from a registry rather than by hand: the pin now originates in a repository the
publisher does not control, so the per-invocation integrity check compares
against a value they cannot quietly rewrite. apiary plugins validate re-derives
it too, so a swapped binary is caught by a command rather than at 3am. A
publisher pin that disagrees with the registry aborts the install.
By hand
The manual procedure needs no network and no registry, and stays fully
supported. Using the bundled source-file reference plugin as the example:
1. Obtain the executable. Build it from source or download a release from the plugin's publisher, then verify the artifact out of band (checksum, signature):
2. Create the plugin's directory under a searched location, named by the plugin id, and copy in the manifest and executable:
mkdir -p .apiary/plugins/dev.apiary.source-file
cp apiary-plugin.json apiary-plugin-source-file .apiary/plugins/dev.apiary.source-file/
chmod +x .apiary/plugins/dev.apiary.source-file/apiary-plugin-source-file
3. Check the installation — discovery and manifest validation, no plugin code executed:
apiary plugins list # dev.apiary.source-file 1.0.0 enabled source
apiary plugins inspect dev.apiary.source-file
apiary plugins validate
Enabling it (either way)
4. Enable it in apiary.yaml. Installation makes a plugin available;
only a plugins: entry makes it run. apiary plugins install prints this
snippet, seeded with the config keys the manifest requires — it never edits your
config itself:
plugins:
- id: dev.apiary.source-file
enabled: true # omitted means true
timeout: 5s # default: 10s, per invocation
config: # validated against the manifest's config_schema
path: /path/to/project/.apiary/incoming-items.json
apiary validate now also validates this instance's config against the
manifest's JSON Schema. Set enabled: false to keep an installed plugin
configured but inert.
5. Restart the daemon. Plugin clients are created at startup; a plugin installed or reconfigured while the daemon runs is picked up on the next start.
Upgrading and uninstalling
apiary plugins upgrade dev.apiary.routines # same checks, then swap
apiary plugins upgrade dev.apiary.routines --rollback # restore the kept version
apiary plugins uninstall dev.apiary.routines
upgrade runs the full verification, sets the current version aside as
<id>.bak (one generation), and commits the new one; if the result does not
validate, the previous version is restored automatically. A running daemon keeps
the version it started with until you restart it.
uninstall removes the directory and refuses while the plugin is still enabled
in apiary.yaml (--force overrides). It never edits your config — a plugins:
entry pointing at an uninstalled id is already a clear apiary validate error.
By hand, the equivalent is: verify the new artifact in a staging directory, stop
Apiary, replace the whole plugin directory atomically, run
apiary plugins validate, and restart (details under
Trust and secrets). To uninstall, remove the plugins:
entry (or set enabled: false) and delete the plugin's directory.
Registries and mirrors
plugin_registries lists the indexes search, info and install resolve
names against. Unset means the official index; each entry is a URL, or a mapping
that pins that registry to a signing key:
plugin_registries:
# The official index, verified against the key built into the binary.
- https://orlandoburli.com.br/apiary/registry/v1/index.json
# An internal mirror, verified against a key this organisation controls.
- url: file:///opt/apiary/registry/index.json
public_key: RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3
Registries are consulted in order and the first hit wins, so a mirror listed
first deliberately shadows the official index. Only https:// and file:// are
accepted: digests protect the payload, but nothing protects a plaintext
resolution. plugin_registries: [] disables the registry entirely, leaving
manual installation. The daemon never reads any of this — registry access is a
CLI concern.
The index is signed with minisign and
verified before it is parsed; the local cache is verified on read too, so a cache
poisoned on disk is caught rather than served. Once a key is pinned there is no
way to skip verification — no flag, no environment variable. When no key is
available for a registry, commands print
! … is not signature-verified (no public key pinned for it) rather than letting
an unverified index read as a checked one.
Signing covers the index, which carries the digests, which cover the artifacts. It does not authenticate plugin publishers; artifact signing is a separate problem and is not solved here.
For air-gapped installs, use a file:// mirror or --offline, which uses the
cached index and never touches the network.
Manifest version 1
{
"schema_version": 1,
"id": "dev.apiary.event-file",
"version": "1.0.0",
"apiary": ">= 0.10.0-0",
"protocol": 1,
"executable": "apiary-plugin-event-file",
"capabilities": ["event_exporter"],
"config_schema": {
"type": "object",
"properties": {"path": {"type": "string", "minLength": 1}},
"required": ["path"],
"additionalProperties": false
},
"security": {
"network": false,
"read_paths": [],
"write_paths": ["configured event file"],
"secret_env": []
}
}
idis a lowercase reverse-DNS identifier and is stable across releases.versionfollows semantic versioning;apiaryis a semantic-version constraint.protocolmust be1. Unknown manifest/protocol versions fail closed.executableis relative to the plugin directory. Absolute paths, traversal, symlinks, non-regular files, and non-executable files are rejected.checksum(optional) pins the SHA-256 of the executable, assha256:<hex>or bare hex. When present it is verified when the plugin client is created and again before each invocation, so a binary replaced after installation is rejected. A malformed value is an error rather than being treated as unpinned, soapiary validatecatches a bad pin, and bothapiary validateandapiary plugins validatere-derive the digest to catch a binary that changed after installation. When the publisher ships no pin,apiary plugins installwrites the registry's digest here. A publisher-written pin is tamper-evidence, not authenticity: the digest lives beside the binary, so anyone able to rewrite the executable can rewrite the pin too — it detects accidental drift and unsophisticated swaps, not a determined attacker with write access. An installer-injected pin originates outside the publisher's own release, which is what makes it a supply-chain check rather than a drift check.capabilitiesacceptssource,runner,workflow_action,approval_provider,secret_provider, andevent_exporter.config_schemasupportstype,properties,required,additionalProperties,enum,items,minLength,maxLength,minimum,maximum,minItems, andmaxItems. Unknown validation keywords fail closed.securityis an inspectable declaration, not an OS sandbox guarantee.
Protocol version 1
The transport is plain stdin/stdout. From the plugin's point of view, one invocation is:
- Apiary starts your executable (fresh process, every time).
- Your stdin receives exactly one JSON object followed by a newline, then stdin is closed. Read it to EOF and decode it — no framing, no length prefix, no second request will ever arrive.
- Do the work.
- Write exactly one JSON object to stdout — the response — and exit
0. Nothing else may go to stdout: a log line, a progress dot, or a second JSON object makes the whole invocation fail as a malformed response. - Anything you want to say for humans (debug output, warnings) goes to stderr. Apiary captures up to 64 KiB of it and attaches it to error messages; it is ignored on success.
In shell terms, Apiary is doing the equivalent of:
Limits: stdout ≤ 4 MiB, captured stderr ≤ 64 KiB, wall-clock capped by the
instance's timeout (default 10s) — the deadline kills the process and fails
that invocation. Exiting non-zero also fails the invocation, even if a
response was written.
The request you receive
{
"protocol": 1,
"request_id": "1784736000000000000",
"capability": "event_exporter",
"method": "export",
"config": {"path": ".apiary/events.jsonl"},
"payload": {"schema_version": 1, "type": "runner.started"}
}
| Field | What to do with it |
|---|---|
protocol |
Refuse anything other than 1 with an error response |
request_id |
Echo it verbatim in your response — Apiary rejects a mismatch |
capability / method |
Select the operation; unknown combinations deserve an error with code unsupported_method |
config |
Your instance's config: block from apiary.yaml, passed on every call — plugins get no other configuration channel |
payload |
The method's input (shape per capability, e.g. source methods); may be absent |
The response you send
Exactly one of result or error, always echoing protocol and
request_id:
{"protocol": 1, "request_id": "1784736000000000000", "error": {"code": "write_failed", "message": "permission denied"}}
An error needs both a non-empty machine-readable code and a human
message; a response carrying both result and error, or neither, is
malformed.
A complete plugin in 20 lines
The contract is small enough that this Python script is a valid plugin — it
answers source.poll with one hard-coded item:
#!/usr/bin/env python3
import json, sys
req = json.load(sys.stdin) # 1. read the single request
def respond(result=None, error=None): # 4. one response object, stdout
print(json.dumps({"protocol": 1, "request_id": req["request_id"],
**({"result": result} if error is None else {"error": error})}))
if req["protocol"] != 1:
respond(error={"code": "unsupported_protocol", "message": "expected protocol 1"})
elif req["capability"] == "source" and req["method"] == "poll":
print("polling upstream...", file=sys.stderr) # diagnostics → stderr only
respond({"items": [{"id": "py-1", "title": "Hello from Python",
"labels": ["origin:python"], "state": "open"}]})
elif req["capability"] == "source" and req["method"] in ("acknowledge", "write_result"):
respond({"ok": True})
else:
respond(error={"code": "unsupported_method",
"message": f"unknown {req['capability']}.{req['method']}"})
Make it executable, name it in a manifest with "capabilities": ["source"],
and it installs like any other plugin. Go and Python authors get all of the
envelope handling for free from the official SDKs, and any
plugin — hand-rolled or not — can be checked against the
conformance kit.
Capability method vocabulary:
| Capability | Protocol methods |
|---|---|
source |
poll, acknowledge, write_result (see Source plugins) |
runner |
configure, run |
workflow_action |
run |
approval_provider |
notify, remind, escalate |
secret_provider |
resolve |
event_exporter |
export |
Protocol envelopes are stable; capability payloads use the corresponding
versioned Apiary internal contract. The runtime integrations are
event_exporter and source. The remaining capability names reserve the same
manifest and transport boundary for their proxies without requiring a new
installation model.
Source plugins
A source-capable plugin is a poll-mode work source: the daemon bridges a
type: plugin entry in sources: to one enabled plugin instance and invokes
it on the source's poll_interval — plugins never push into the daemon.
plugins:
- id: com.example.nagios
timeout: 10s
config:
api_url: https://nagios.internal/api
sources:
- id: nagios-alerts
type: plugin
poll_interval: 30s
config:
plugin: com.example.nagios # the plugin instance to bridge
filters:
labels: ["severity=critical"]
plugin is the only key the source entry accepts, and apiary validate
rejects any other: the bridge forwards nothing from here to the plugin process.
The plugin's own settings belong under plugins[].config, where they are
checked against the manifest's JSON schema — a setting written on the source
entry would be silently dropped.
Methods (wire types in sdk/plugin/source.go):
| Method | Payload → result |
|---|---|
poll |
{since, states, labels} → {items: [...]} — the current work items; states/labels are the source's filters, forwarded for backend-side filtering |
acknowledge |
{item, action} → {ok: true} — called after dispatch; return ok if there is nothing to mark |
write_result |
{item, success, output, error} → {ok: true} — the run outcome; return ok if there is no result surface |
Each item's id is the dedup key: keep it stable per dispatch-worthy
occurrence and re-return ongoing items on every poll — the daemon's
task/instance dedup prevents re-dispatch, exactly as for the in-tree
monitoring sources. Items without an id are dropped. Timestamps are RFC3339
strings; labels use key:value form and drive workflow trigger matching.
Plugin-backed sources are read-only: they cannot host approval steps, CI
waits, set_state, or label write-back — apiary validate rejects workflows
that require those against a type: plugin source. Publish findings to a
ticket source (APIARY_PUBLISH / APIARY_SPAWN) instead.
Go plugin authors can go get github.com/orlandoburli/apiary/sdk — a
standalone, standard-library-only module, tagged sdk/vX.Y.Z independently of
the daemon — then import github.com/orlandoburli/apiary/sdk/plugin and call
plugin.Main(handler). The SDK decodes one request, rejects protocol mismatches,
echoes the request ID, and emits the structured response envelope. Plugins in
other languages implement the JSON contract directly — the
Plugin SDK page has the full Go reference plus verified
Python, Rust, and Bash examples.
Trust and secrets
Plugins execute with the Apiary service account's OS permissions. Before install:
- Obtain the binary from a trusted publisher — read the code you are about to run as that account. A registry listing is a pointer to someone else's repository, reviewed but not endorsed, and never a substitute for this.
- Verify its provenance.
apiary plugins installdoes the mechanical part: digests checked before unpacking, manifest cross-checked against the listing, executable pinned. Installing by hand means doing it out of band yourself. - Inspect the manifest's network, path, and secret requirements —
installprints them and waits for you;apiary plugins inspectshows them any time. - Install into a directory writable only by the Apiary operator/service account.
How far the checks reach. Signature verification covers the registry index,
which carries the digests, which cover the artifacts. It does not authenticate
plugin publishers, and it says nothing about what a plugin does with the access
it declares. A checksum the publisher wrote is tamper-evidence only — the pin
lives beside the binary, so anyone who can rewrite one can rewrite the other. A
pin injected at install comes from the registry repository instead, which is a
meaningfully stronger claim, but still one about bytes, not about intent.
The daemon never contacts a registry, never downloads, and never auto-upgrades:
every one of those is a command you run. Upgrade with
apiary plugins upgrade, which stages, verifies, keeps one generation as
<id>.bak, and restores it if the new copy fails to validate — or, by hand,
verify the new artifact in a staging directory, stop Apiary, replace the complete
plugin directory, run apiary plugins validate, and restart. Keep the old
directory for rollback, but never leave two versions with the same ID in searched
directories.
Child processes receive a minimal environment (PATH, home/temp/locale/timezone)
plus only variables listed in security.secret_env. Secret values are never
included in protocol requests or error messages. Plugin configuration should
contain references or non-secret options, not credentials. Event exporters
receive the persisted event after Apiary's metadata redaction.
Process isolation is not a security sandbox: a malicious executable can still use the service account's filesystem and network access. Use OS-level sandboxing, containers, restricted service users, and egress controls where the trust level requires them.
Reference plugins
For plugins you can install rather than copy, see the Plugin Directory.
Each directory under src/examples/plugins/ ships a manifest, a README with
build/install commands, and a complete plugin:
| Plugin | Language | Demonstrates |
|---|---|---|
event-file |
Go (SDK) | An event_exporter appending one redacted event JSON object per line |
source-file |
Go (SDK) | A source plugin polling work items from a JSON file |
source-bash |
Bash + jq | The same file-backed source as a shell script — no build step at all |
source-node |
TypeScript (Node) | The same file-backed source compiled with tsc to a shebang executable |
The three source plugins are behaviorally identical on purpose: diff them to see the same contract expressed in each ecosystem.