Plugin Development Guide¶
Ductile is built on a spawn-per-command model. A plugin is any executable
that reads one JSON request from stdin, writes one JSON response to
stdout, and exits. There is no daemon, no shared memory, no in-process
state.
Durable plugin memory is the append-only plugin_facts stream. A plugin
that needs to remember anything across invocations declares a fact_outputs
rule in its manifest, returns a stable snapshot from its durable command,
and lets core record that snapshot append-only and rebuild the compatibility
view automatically. This guide treats the manifest as the contract that
drives plugin quality — every directive is explained below and exists to
push you toward the correct shape. If you find yourself wanting to do
something the manifest doesn't sanction, that is usually a signal to step
back rather than add a workaround.
See Plugin Facts for the canonical reference and worked examples of the durability contract.
1. The Lifecycle¶
When a job is triggered (via scheduler, API, or webhook):
- Ductile forks the plugin entrypoint as a fresh process.
- The core writes a request envelope (JSON) to the plugin's
stdin. - The plugin processes the command and writes a response envelope
(JSON) to
stdout, then exits. - Ductile captures
stderrfor logging and kills the process if it exceeds the timeout.
Because every invocation is a fresh process, the plugin has no in-memory
state across calls. Anything the plugin needs to remember must come back
through the request envelope's state field on the next invocation.
2. The plugin protocol¶
2.1 Request Envelope (Core → Plugin)¶
{
"protocol": 2,
"job_id": "uuid",
"command": "poll | handle | health | init",
"config": {},
"state": {},
"context": {},
"event": {},
"deadline_at": "ISO8601"
}
| Field | What it is |
|---|---|
protocol |
The wire-protocol version. Plugins declare which version they expect in manifest.protocol; mismatch refuses load. |
job_id |
Ductile-assigned unique id for this invocation. Useful in logs and downstream events. |
command |
The command the plugin is being asked to run. Always one of poll, handle, health, init (plus any plugin-declared command name). |
config |
The static plugin config from the operator's YAML, with ${ENV} interpolated. Read-only. |
state |
The plugin's current compatibility-view row — i.e. the latest fact's snapshot for plugins that declare fact_outputs, or the direct-write plugin_state row for plugins that have not yet migrated. Treat it as "what I knew last time." |
context |
Shared baggage carried across the pipeline chain. Operator-declared, immutable in the receiving plugin. |
event |
Present only for handle. The triggering event envelope from upstream. |
deadline_at |
Informational ISO8601 timestamp. Plugins may abandon long work early; core enforces the real deadline externally. |
2.2 Response Envelope (Plugin → Core)¶
{
"status": "ok | error",
"result": "short human-readable summary",
"error": "human-readable message (when status=error)",
"retry": true,
"events": [],
"state_updates": {},
"logs": []
}
| Field | What it is |
|---|---|
status |
ok for success, error for any failure. |
result |
Required when status=ok. Short human-readable summary of what happened. Surfaces in ductile job inspect, the watch UI, and as the result for synchronous pipelines. |
error |
Required when status=error. Human-readable diagnostic. |
retry |
Response-envelope compatibility signal. Defaults true if omitted. Set false only when retrying the same request cannot succeed (configuration error, permanent input invalid). Core owns the final retry decision; this is a fact about the failure, not a policy instruction. |
events |
Array of {type, payload, dedupe_key?} envelopes that drive downstream pipeline routing. |
state_updates |
The plugin's emitted snapshot for this invocation. When the manifest declares a matching fact_outputs rule, core records this snapshot append-only as a plugin_facts row and rebuilds the compatibility view (plugin_state) from it. See §3.4. |
logs |
Array of {level, message}. Stored with the job record. |
2.3 What state_updates Is, And What It Is Not¶
state_updates is the snapshot of the plugin's observed durable state at
the end of this invocation. It is not a partial patch and it is not a
running diary of actions taken.
A correct snapshot:
- Is a full object representing the plugin's durable observed state.
- Contains the same keys every invocation that command runs (presence-stable).
- Is deterministic: the same observed inputs produce the same bytes out.
- Has a clear cache-view story: a downstream reader of the latest snapshot understands what the plugin knows.
An incorrect snapshot (anti-patterns — see §6):
- A counter that increments each invocation (
executions_count). - A timestamp that updates whether or not anything was observed (
last_run). - A diff or patch (
{"new_id": "abc"}). - An ordered set built from
set()union (non-deterministic order).
If a plugin emits action bookkeeping rather than observed state, it should
emit no state_updates at all. Action bookkeeping belongs in job_log,
which is captured automatically.
2.4 Framing And Errors¶
- One JSON request on stdin → one JSON response on stdout. Not JSON Lines, not length-prefixed.
- Exit code
78(EX_CONFIG) marks a permanent configuration failure and is treated as non-retryable regardless of theretryfield. - If the request
protocolfield doesn't match what the plugin expects, the plugin should exit78with a clear error on stderr.
3. The Manifest (manifest.yaml)¶
The manifest is the single source of truth for what the plugin is, what it does, and how its memory works. Treat reading this section top-to-bottom as a quality checklist for any new plugin.
3.1 Top-Level Fields¶
manifest_spec: ductile.plugin # required
manifest_version: 1 # required
name: my_plugin # required
version: 0.1.0 # required
protocol: 2 # required
entrypoint: run.py # required
description: "What this plugin does, in one sentence." # optional but recommended
concurrency_safe: true # optional; default true
commands: [...] # required, at least one
fact_outputs: [...] # required for any plugin with durable memory
config_keys: # optional; declares config contract
required: [...]
optional: [...]
manifest_spec (required)¶
Must be the literal string ductile.plugin. Identifies this YAML as a
ductile plugin manifest. Future manifest families (e.g. an event spec) would
use a different identifier.
manifest_version (required)¶
Must be the integer 1. Ductile uses this to evolve manifest semantics
accretively without breaking existing plugins.
name (required)¶
The plugin's identity. Must be unique across all plugin roots — first plugin discovered with a given name wins; later duplicates are ignored. Use underscores or hyphens, no spaces. Pipelines, schedules, and routes refer to the plugin by this name.
version (required)¶
The plugin's release identity over time. Free-form string; prefer
semver-compatible MAJOR.MINOR.PATCH. Bump when behaviour changes so
operators can correlate facts and job logs to plugin version.
protocol (required)¶
Must be 2. Declares the wire protocol version this plugin understands.
Mismatch refuses load; do not lie about protocol support.
entrypoint (required)¶
Path to the executable, relative to the plugin directory. Must be marked
executable (chmod +x). The shebang line picks the interpreter. No ..
allowed (path traversal prevention). Examples: run.py, run.sh,
./bin/dispatcher.
description (optional, recommended)¶
Short human-readable summary of what the plugin does. Surfaces in operator inspection and LLM-driven tools. Treat it as the answer to "what does this plugin do?" in one sentence.
concurrency_safe (optional, default true)¶
Concurrency hint. false tells the runtime that the plugin is not safe
to run two of in parallel — typically because it owns a single-writer
durable resource (a SQLite DB it writes to, an OAuth token table) and
parallel execution would race the writer. When false, runtime defaults to
serial execution unless the operator explicitly overrides with
plugins.<name>.parallelism > 1.
If you have any doubt, set false. Concurrency-safe is a property the
plugin author asserts and the runtime trusts.
commands (required)¶
Array of command declarations. Every command the plugin can be invoked with
must be listed, with at least name and type. See §3.2.
fact_outputs (recommended for any plugin with durable memory)¶
Declares which commands emit durable facts and how the compatibility view is rebuilt. If your plugin needs to remember anything across invocations, declare this. See §3.4.
config_keys (optional)¶
Declares the static config contract:
config_keys:
required: [client_id, client_secret, db_path]
optional: [request_timeout, lookback_days]
required keys missing at load time refuse the plugin to load. optional
keys are documented for operators but not enforced. Keep this list honest —
it is the contract the operator's YAML satisfies.
3.2 The commands Array¶
Each command is a pure function on (config, state, context, event) →
response. The manifest declares the command's identity, its side-effect
class, its input/output shape, and its retry properties.
commands:
- name: poll
type: read
description: "Fetch latest detections; emit one event per first-of-day species."
idempotent: true
retry_safe: true
input_schema: {}
output_schema:
status: string
events: array
state_updates: object
values:
consume: []
emit:
- event: birdnet.firstday_species
values:
- payload.scientific_name
- payload.common_name
- payload.first_id
- payload.detected_at
name (required)¶
The command's identity inside this plugin. Standard names that the runtime
recognises: poll, handle, health, init. Plugins may declare additional
names (e.g. token_refresh in withings); those are invocable via API and
schedules but do not get the standard-command convenience routing.
| Standard name | Purpose | Typical type |
|---|---|---|
poll |
Scheduled durable observation. Emits events on observed change; emits a snapshot in state_updates. |
read |
handle |
Event-driven response. Receives an upstream event, processes it, optionally emits downstream events. | write (usually) |
health |
Diagnostic check. Emits no state_updates. |
read |
init |
Capability discovery / affordance bundle for LLM tools. Emits no state_updates. |
read |
type (required)¶
read or write. This is about external observable side effects, not
about whether the command emits durable facts:
type: read— no external POST/PUT/DELETE. Idempotent under retry. Examples:poll,fetch,get,list,health. Areadcommand can still emitstate_updates(the durable observation snapshot) and can still write to a local SQLite DB the plugin owns; the constraint is on external state.type: write— modifies external state via the network. Examples:sync,send,notify,oauth_callback,delete. Default iftypeis omitted (paranoid default).
type determines the token scope required to invoke the command
(plugin:ro vs plugin:rw).
description (optional)¶
Short human-readable summary of what this command does. Critical for the TUI, the watch UI, and LLM operators reading capability discovery.
idempotent (optional, boolean)¶
Hint that calling this command N times produces the same observable result
as calling it once, given identical inputs. Used by the runtime to make
safer retry decisions. Be honest: a sync that posts measurements to a
remote API is not idempotent unless the remote API deduplicates.
retry_safe (optional, boolean)¶
Hint that this command is safe to retry on transient failure. Stronger than
idempotent in practice because it accounts for partial-side-effect risk
during retry. Default to false if you are unsure.
input_schema / output_schema (optional, legacy)¶
Either a full JSON Schema object or a compact field: type map. Documents
the request payload and response shape for API consumers and operators. The
compact form expands automatically:
These remain useful as a typed surface but are not the durability contract
— that is values plus fact_outputs.
values (optional but recommended)¶
Names-only payload contract — the Hickey-faithful successor to typed
schemas for pipeline authoring. values.consume declares which payload
names this command reads from the request. values.emit declares, per
emitted event type, which payload names the event carries.
values:
consume:
- payload.url
- payload.depth
emit:
- event: jina_reader.scraped
values:
- payload.url
- payload.text
- payload.content_hash
Rules:
- Entries are payload names, not types. Format:
payload.<key>orpayload.<key>.<sub>for nested keys;payload.*matches all. - Pipeline authors use
with:to remap durable context into the request payload a downstream plugin expects, andbaggage:to claim which event payload names become durable context. The plugin'svaluesdeclaration is a sanity-aid for that authoring. valuesdoes not decide durability. Durability is decided by pipelinebaggage:(for event payloads becoming context) and byfact_outputs(forstate_updatesbecomingplugin_facts).
3.3 fact_outputs — The Durability Declaration¶
This is the directive that decides whether your plugin participates in the append-only fact model.
fact_outputs:
- when:
command: poll
from: state_updates
fact_type: my_plugin.snapshot
compatibility_view: mirror_object
A fact_outputs rule says: "when command poll succeeds, take its
emitted state_updates, record it append-only as a my_plugin.snapshot
fact, and rebuild the plugin_state row by mirroring the snapshot."
when.command (required)¶
The command name whose successful response produces this fact. One rule per
command-that-emits-durable-state. A plugin may declare multiple rules
(e.g. withings declares one for poll and one for token_refresh,
because both observe durable state).
from (required)¶
Currently must be the literal string state_updates. The fact is sourced
from the plugin's emitted snapshot. Future protocol versions may add other
sources (e.g. a structured facts field); they will be accretive additions,
not breaking changes.
fact_type (required)¶
The fact's identity. Convention: <plugin_name>.<noun>, where the noun
describes the kind of observation. Most migrated plugins use
<plugin_name>.snapshot. Use a different noun only if the plugin emits
distinct kinds of observation that downstream readers should differentiate.
compatibility_view (optional, default mirror_object)¶
How plugin_state is rebuilt from the latest fact. Currently the only
supported value is mirror_object: replace plugin_state.state wholesale
with the latest fact's fact_json. This is exactly what legacy readers
expect, so the migration is transparent.
Future view policies (e.g. a reducer that folds multiple facts) would be
added as new enum values; today, mirror_object is the right answer.
3.4 The Plugin Memory Model In One Diagram¶
plugin emits state_updates snapshot
│
▼
┌───────────────────────────────────────────────┐
│ core (manifest fact_outputs rule) │
└───────────────────────────────────────────────┘
│
┌──────────────────────┴──────────────────────┐
▼ ▼
plugin_facts (append-only, plugin_state (compatibility view,
the durable record): rebuilt automatically):
one row per invocation, one row per plugin,
{seq, fact_type, fact_json, ...} {plugin_name, state, updated_at}
The plugin author writes only the snapshot. Core does the rest. The
compatibility view exists so legacy readers (the request envelope's
state field, operator inspection, schedules that read prior state) keep
working without change.
4. Worked Examples¶
4.1 Minimal Plugin (no durable memory)¶
A plugin that emits a single event when its health is checked. No durable
state, no fact_outputs needed.
plugins/notify_echo/manifest.yaml:
manifest_spec: ductile.plugin
manifest_version: 1
name: notify_echo
version: 0.1.0
protocol: 2
entrypoint: run.sh
description: "Emits an echo event when polled. Stateless."
concurrency_safe: true
commands:
- name: poll
type: read
description: "Emits one notify_echo.tick event."
idempotent: true
retry_safe: true
values:
consume: []
emit:
- event: notify_echo.tick
values:
- payload.message
- payload.emitted_at
- name: health
type: read
description: "Reports plugin reachability."
idempotent: true
retry_safe: true
config_keys:
optional: [message]
plugins/notify_echo/run.sh:
#!/usr/bin/env bash
set -euo pipefail
REQUEST=$(cat)
COMMAND=$(echo "$REQUEST" | jq -r '.command')
MESSAGE=$(echo "$REQUEST" | jq -r '.config.message // "tick"')
case "$COMMAND" in
poll)
cat <<EOF
{
"status": "ok",
"result": "Emitted notify_echo.tick",
"events": [{
"type": "notify_echo.tick",
"payload": {
"message": "$MESSAGE",
"emitted_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
}],
"logs": [{"level": "info", "message": "Emitted notify_echo.tick"}]
}
EOF
;;
health)
cat <<EOF
{"status": "ok", "result": "healthy", "logs": [{"level": "info", "message": "ok"}]}
EOF
;;
*)
cat <<EOF
{"status": "error", "error": "Unknown command: $COMMAND", "retry": false}
EOF
;;
esac
Notice:
- No
state_updates. This plugin has no durable memory, so it declares nofact_outputs. pollistype: readandidempotent: true— it observes time and emits, with no external side effect.healthistype: read, mutates nothing.
4.2 Canonical Durable Plugin (poll with snapshot + facts)¶
A polling plugin that watches a SQLite database, emits an event on each new row crossing a threshold, and remembers the last result so the next poll can detect change.
plugins/sqlite_change/manifest.yaml:
manifest_spec: ductile.plugin
manifest_version: 1
name: sqlite_change
version: 0.3.0
protocol: 2
entrypoint: run.py
description: "Polls a SQLite query; emits on threshold crossing."
concurrency_safe: false
commands:
- name: poll
type: read
description: "Run query, emit on threshold crossing, return snapshot."
idempotent: true
retry_safe: true
values:
consume: []
emit:
- event: data.changed
values:
- payload.result
- payload.previous_result
- payload.detected_at
- name: health
type: read
description: "Report db reachability."
idempotent: true
retry_safe: true
fact_outputs:
- when:
command: poll
from: state_updates
fact_type: sqlite_change.snapshot
compatibility_view: mirror_object
config_keys:
required: [db_path, query, event_type]
optional: [threshold_op, threshold_value, message_template]
plugins/sqlite_change/run.py:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = []
# ///
import json
import sqlite3
import sys
from datetime import datetime, timezone
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def snapshot_state(*, last_result, last_checked_at, last_triggered_at):
"""Pure constructor for the full compatibility snapshot.
Every field is required at every call site — the helper never inherits
silently. Callers that don't observe a field this invocation pass the
prior state value explicitly.
"""
return {
"last_result": last_result,
"last_checked_at": last_checked_at,
"last_triggered_at": last_triggered_at,
}
def poll_command(request):
config = request.get("config", {})
state = request.get("state", {})
# Observe durable state.
with sqlite3.connect(config["db_path"]) as conn:
result = conn.execute(config["query"]).fetchone()
scalar = str(result[0]) if result else None
timestamp = now_iso()
triggered = scalar != state.get("last_result")
events = []
if triggered:
events.append({
"type": config["event_type"],
"payload": {
"result": scalar,
"previous_result": state.get("last_result"),
"detected_at": timestamp,
},
})
# Build the snapshot. The full compatibility-view shape is emitted every
# time, even for fields this invocation did not change — the helper
# guarantees that.
return {
"status": "ok",
"result": f"observed result={scalar} triggered={triggered}",
"events": events,
"state_updates": snapshot_state(
last_result=scalar,
last_checked_at=timestamp,
last_triggered_at=timestamp if triggered else state.get("last_triggered_at"),
),
"logs": [{"level": "info", "message": f"polled: {scalar}"}],
}
def main():
request = json.load(sys.stdin)
command = request.get("command")
if command == "poll":
response = poll_command(request)
elif command == "health":
response = {"status": "ok", "result": "healthy"}
else:
response = {"status": "error", "error": f"Unknown command: {command}", "retry": False}
json.dump(response, sys.stdout)
if __name__ == "__main__":
main()
Notice:
fact_outputsdeclaressqlite_change.snapshotmirrored frompoll'sstate_updates. That single declaration is what makes core record an append-only fact every poll and rebuildplugin_stateautomatically.concurrency_safe: falsebecause the plugin owns a single-writer observation cycle.snapshot_stateis a pure constructor. Every field is explicit at every call site — no sentinel-Noneoverlay, no implicit inheritance from prior state. The caller carrieslast_triggered_atforward by reading it fromstateand passing it explicitly.- The snapshot has the same three keys every invocation. A no-change
poll emits a snapshot byte-identical to the prior one, which keeps
plugin_factsfree of reordering noise. healthdoes not returnstate_updatesand does not mutate durable state.
5. The health And init Pattern¶
health and init are diagnostic-only. Neither should emit
state_updates. The reasons are concrete:
healthruns from the watch UI, the operator's CLI, and circuit-breaker half-open probes. Ifhealthmutated durable state, every diagnostic click would create a new fact with no observed change.initreturns an LLM/tool affordance bundle for capability discovery. Its output is a function of static metadata, not observed state.
If a plugin's health or init is currently emitting state_updates, that
is a bug — remove the emission. The first post-deploy poll or
token_refresh will replace plugin_state wholesale via mirror_object,
sweeping any historical residue.
6. What Does Not Belong In state_updates¶
These are the explicit non-candidates for state_updates / fact_outputs.
None of them should live in state_updates or in a fact_outputs rule.
They belong in job_log (which captures all of them automatically) or
nowhere at all.
| Pattern | Why it's wrong |
|---|---|
last_run, last_invoked_at |
Action trace. Updates whether or not anything was observed. Use job_log for run history. |
executions_count, total_calls |
Monotonic counter of actions taken. Not observed durable state. |
last_pattern, last_prompt, last_video_id |
Single-field "the most recent thing I did" markers. Diagnostic, not durable observation. |
last_summary, last_error_message |
Action diagnostics. Belongs in logs. |
last_health_check, last_init_at |
Diagnostic timestamps from non-mutating commands. |
| Diff or partial-patch shapes | The compatibility view is rebuilt wholesale; partial patches lose information on the next mirror. |
Lists derived from set() union |
Non-deterministic order produces a different snapshot on every poll even when nothing changed. |
If your plugin has a candidate field and you're not sure whether it's observed state or action bookkeeping, ask: "if a downstream reader reads this field, are they learning about an external observation, or about my plugin's own behaviour?" External observation belongs in the snapshot. Plugin behaviour does not.
7. Event Payload Convention¶
Plugins should follow standard payload field conventions for interoperability. These are event payload conventions, not state conventions — they live alongside the durability model, not in conflict with it.
7.1 Standard Fields¶
| Field | Type | Purpose | Used By |
|---|---|---|---|
text |
string | Primary text content for processing | Required if producing text for downstream steps |
result |
string | Final human-readable output | Terminal plugins (fabric, summarizers) |
source_url |
string | Originating URL | Web scrapers, YouTube fetchers |
source_type |
string | Content origin hint | All plugins |
7.2 Source Types¶
web— web page content (jina-reader)youtube— YouTube video transcriptfile— local file contentllm— LLM-generated content (fabric, claude, etc.)
7.3 Event Type Naming¶
<plugin_name>.<past_tense_verb>. Examples:
jina_reader.scrapedyoutube_transcript.fetchedfabric.completedfile_handler.readfile_handler.written
7.4 Pipeline Integration¶
The core dispatcher automatically propagates these payload names from input events to output events:
pattern,prompt,modeloutput_dir,output_path,filename
Plugins do not need to manually copy these fields — the dispatcher handles propagation. Just emit your event with the standard fields and the pipeline DSL takes care of the rest.
7.5 Baggage (Context) Fallback¶
Only payload names claimed by a pipeline's baggage: declaration become
durable context entries in the event_context ledger. Downstream plugins
receive that accumulated baggage in request.context. If a field may be
produced by an upstream step, prefer:
- Read from
event.payload(step-specific input). - Fall back to
request.contextfor accumulated values.
This makes pipelines resilient when intermediate plugins emit narrower payloads.
7.6 Example Event Payload¶
return {
"status": "ok",
"result": "Scraped https://example.com",
"events": [{
"type": "jina_reader.scraped",
"payload": {
"url": "https://example.com",
"source_url": "https://example.com",
"source_type": "web",
"text": "Scraped content here...",
"content_hash": "abc123"
}
}]
}
8. Built-in Plugin: if Classifier¶
The if plugin is a general-purpose field classifier. It evaluates an
ordered list of checks against a payload field and emits the first
matching event type, with the payload unchanged.
8.1 Config (per instance)¶
plugins:
check_youtube:
enabled: true
config:
field: text
checks:
- contains: "youtu.be"
emit: youtube.url.detected
- contains: "youtube.com"
emit: youtube.url.detected
- startswith: "http"
emit: web.url.detected
- default: text.received
8.2 Supported Checks¶
contains,startswith,endswith,equals(case-insensitive)regex(Pythonre.fullmatchagainst the field value)default(always matches if reached)
8.3 Semantics¶
- Checks are evaluated in order; first match wins.
- Missing fields are treated as empty strings.
- No match + no default →
status: errorwithretry: false. Core treats that as a v2 compatibility signal for a permanent failure.
8.4 Instance Naming¶
Ductile uses manifest names as plugin identities. To create multiple
instances of if (or any plugin), use plugin aliasing in plugins.yaml:
plugins:
check_youtube:
uses: if # inherit the if plugin's implementation
enabled: true
config:
field: text
checks: [...]
The aliased instance has its own config, its own facts, and its own compatibility-view row.
9. Filesystem & Diagnostic Bundles¶
Ductile does not provision a workspace directory for plugins. The core is dispatch, state, and routing; filesystem is the plugin's concern.
- If your plugin needs a scratch path, create it yourself. For
ephemeral work prefer
mktemp -d(or the language equivalent) and clean up on exit. For persistent caches use~/.cache/ductile-<plugin>/or a path declared in your plugin config and validated at startup. - If your plugin needs an archive of its own stdout (for offline
debugging or external log shipping), tee it from inside the run script
before writing the response envelope, e.g.
tee -a "$HOME/.cache/myplugin/stdout.log". Core does not write subprocess stdout to disk on your behalf; the operationally meaningful fragments are already captured in the database (job_log,plugin_facts,event_context). - Cwd. Plugin subprocesses inherit the dispatcher's working
directory. If your plugin cares where it runs, the run script should
cdto a path of its own choosing.
10. Security & Isolation¶
- Allowed paths. Plugins should only read/write paths they themselves create (per the previous section) or paths explicitly named in their config.
- Execution. Plugins run as the same OS user as the gateway. Use filesystem permissions to limit blast radius.
- Trust. Ductile refuses to load plugins with world-writable directories
or
..in theirentrypoint. The entrypoint must bechmod +x. - No persistent state outside what is declared. Plugins must not write
to their own plugin directory at runtime. Anything durable goes through
state_updates(subject to the manifest'sfact_outputsrule); anything ephemeral goes to a plugin-managed scratch path.
11. Quick Quality Checklist¶
When you finish a new plugin, walk this list before merging:
-
manifest_spec,manifest_version,name,version,protocol: 2,entrypointset. -
descriptionis a real one-sentence summary. -
concurrency_safeis honestly set (falseif the plugin owns a single-writer durable resource). - Every command has
name,type,description, and honestidempotent/retry_safeflags. - Standard commands (
poll,handle,health,init) follow the conventions in §3.2. -
healthandinitemit nostate_updates. - Each command declares
values.consume/values.emitso pipeline authors can see the contract. - If the plugin remembers anything across invocations, it declares
fact_outputsfor the durable command(s). - The emitted snapshot is a full object, deterministic, and has the same keys every invocation of that command (presence-stable).
- Nothing in
state_updatesmatches the §6 anti-patterns. -
config_keys.requiredis honest — required keys must actually be required. - Entrypoint is
chmod +x. - Tests cover the snapshot constructor and the response shape.
If every box ticks, the plugin is aligned with the durability model and should not need a future migration sprint to fix.
Stopwatch — timing is captured for you¶
The dispatcher times every plugin invocation automatically. You do not need
to wrap your handler in time.now() calls; the supervisor records a
stopwatch.Record (plugin id, step, monotonic duration, status, etc.) to
the job_stopwatch table in the ductile DB. Telemetry is system data; it
lives in the supervisor's ledger, not in your request context or response.
See PLUGIN_DIAGNOSTICS.md for the data shape and
the gateway_time formula.
Optional sub-spans for plugin-internal phases¶
If you want to break down what your handler did internally (db_query,
http_call, parsing, etc.), emit a list under ductile_stopwatch_subs at
the top level of your response. Shape:
{
"status": "ok",
"result": "...",
"ductile_stopwatch_subs": [
{"name": "fetch.http_get", "dur_ns": 31000000, "status": "ok"},
{"name": "fetch.body_read", "dur_ns": 11000000, "status": "ok", "bytes": 482103},
{"name": "fetch.decode", "dur_ns": 2400000, "status": "ok"}
]
}
Field convention¶
| Field | Required | Notes |
|---|---|---|
name |
yes | Dotted name <plugin>.<phase> (e.g. fetch.http_get). Prefix enables filtering. |
dur_ns |
yes | Monotonic duration in nanoseconds. Use time.perf_counter_ns() deltas. |
status |
optional | ok / err / skip. Explains zero-duration or partial spans. |
bytes |
optional | For I/O spans (body reads, file hashes). Quartile bytes vs. dur_ns to find slow servers. |
count |
optional | For batch spans (files scanned, watches polled, retries attempted). |
The dispatcher stores additional fields verbatim, but the convention above is what downstream tools query. New fields should be added by RFC, not by ad-hoc plugin choice — the convention is only as strong as its exemplars.
See plugins/_lib/_stopwatch.py for a vendored Python helper that emits
this shape from a context-manager API. The four exemplar plugins (fetch,
file_watch, folder_watch, sys_exec) use it.
Rules¶
- Sub-spans are advisory. The dispatcher's own Record is always emitted regardless of whether you include sub-spans. A buggy or lying plugin poisons its own breakdowns only; the supervisor's timing is independent.
- The dispatcher caps the list at 32 entries per invocation by default. If you emit more, the excess is dropped (head-keep — first 32 survive) and one warning is logged for the call. Order matters: put high-signal spans first. The default is appropriate for almost all plugins; see "Raising the cap" below before considering an override.
- Malformed entries are dropped silently. Non-object items and non-list values do not raise. Defensive parsing is part of the contract.
- The dispatcher does not interpret sub-span fields beyond storing them. Field semantics are the plugin's responsibility. Follow the convention above so quartile dashboards work across plugins.
Raising the cap (rare)¶
A plugin that legitimately produces more than 32 spans — typically a
multi-stage pipeline coordinator with structurally distinct sub-phases —
may declare a higher cap in its manifest.yaml:
Range: [1, 256]. The hard upper of 256 is a system-wide invariant —
every consumer of subs_json (DB row, API response, log line, future
dashboards) needs a stable budget. A manifest declaring max_subs > 256
is rejected at plugin load with a warn-level log; the plugin is not
registered. Manifests omitting the field, or setting it to 0, use the
default 32 — no behaviour change for existing plugins.
Before adding a stopwatch block, ask: can the spans be aggregated? The
default cap is a design forcing function. Aggregation patterns (above)
will handle 95% of cases that initially look like "I need more spans."
Aggregation patterns¶
The 32 cap pushes you toward aggregation over per-event tracing. Patterns that work well within the cap:
| Anti-pattern | Pattern |
|---|---|
| One span per file in a 5000-file scan | One <plugin>.fingerprint_total with count=5000, bytes=... |
| One span per HTTP retry | One <plugin>.http_get with count=<attempts> annotation |
| One span per loop iteration | Histogram buckets: <plugin>.loop.bucket_0_10ms, …bucket_10_100ms, … |
| One span per polled watch (many watches) | Aggregate totals, plus outlier-only per-watch spans (> 50ms) |
For real per-event tracing, use an external tracing backend (OTel) — the stopwatch ledger is for quartile-grade aggregation, not timeline reconstruction.