SOP Fan-In: Overview
A fan-in is an external event source that starts SOP runs. Each source delivers events to the SOP engine through dispatch_sop_event, which matches every event against every loaded SOP’s triggers and starts runs for those that match.
One ZeroClaw instance can bind several fan-ins at once: an MQTT topic, a filesystem path, and an AMQP routing key can all feed the same engine without separate processes. Each source has a dedicated guide below.
How dispatch works
- One matcher path: a single matcher evaluates every trigger type, so matching behaves the same regardless of source.
- Run-start audit: started runs are persisted via
SopAuditLogger. - Headless execution: cron triggers and webhook deliveries drive their runs to completion. The maintenance tick hands each started cron
ExecuteSteporDeterministicStepto the headless run driver, and the webhook route hands each startedExecuteStepto it (dispatch already runs deterministic steps); the driver executes the step as the SOP’s own agent. Sources that still have no live driver logExecuteStepactions as pending throughprocess_headless_resultsinstead of executing them. - Headless ownership: a headless step runs as the agent named by the step’s
agent, falling back to the SOP’sagent, and that agent must be configured and enabled. There is no ambient agent turn to inherit an identity from, so a procedure with no owner is rejected at authoring time and refused at dispatch; it is never run as some other configured agent. A manual SOP is the one case that saves without an owner, becausesop_executesupplies one; the dashboard’s run endpoint, which has no agent behind it, refuses to start such a procedure. - Headless tool scope: a headless step turn is narrowed exactly like a live one: the SOP control tools (
sop_execute,sop_advance,sop_approve) are always removed so a step cannot drive its own run, and the step’s declaredscopeis applied on top whensop.step_scope_enforceis set. A child agent spawned from the step inherits the same exclusions. - Untrusted input: topic and payload text are capped, normalized, prompt-guard screened, and framed before reaching model context.
Sources
Every SOP trigger type, its fields, and its dispatch status, projected directly from the SopTrigger registry:
| Type | Fields | Notes |
|---|---|---|
mqtt | topic, optional condition | MQTT message arrival. Live: delivered by the MQTT listener. |
webhook | path | Inbound HTTP request. Live: gateway /sop/* and SOP-first /webhook routes. |
cron | expression | Time-based firing. Live: dispatched by the SOP maintenance tick (daemon / channel-start paths). |
peripheral | board, signal, optional condition | Hardware signal. Defined and matched, but no peripheral listener feeds it. |
filesystem | path, optional condition, optional events | Filesystem change. Live: delivered by the filesystem watcher. |
calendar | calendar_source, optional calendar_ids, optional condition | Calendar event state. Defined and matched, but no poller feeds it live. |
channel | channel, optional alias, optional condition | Inbound message or forge/platform event on a configured channel (telegram, discord, slack, git, …). Live: delivered by the channel orchestrator when the channel’s SOP dispatch is enabled. The Git forge producer sets an event topic of the form <channel>.<alias>:<event_type> and puts event_type in the payload, so an authored condition filters forge events by type without a second trigger shape. |
manual | none | Agent-initiated run via the sop_execute tool. Not an external fan-in. |
amqp | routing_key, optional condition | AMQP delivery. Live: delivered by the AMQP consumer in a SOP dispatch mode. |
Each source has a dedicated guide in the sidebar. Live sources (delivered by a running listener or authenticated gateway request) start runs as events arrive; cron triggers are dispatched by the daemon’s periodic SOP maintenance tick; agent-initiated runs start from inside an agent turn via sop_execute; the remaining defined-but-unwired sources (peripheral and calendar) validate and match but have no live event source routing into the dispatcher yet.
Security defaults
| Concern | Mechanism |
|---|---|
| Webhook authentication | Gateway pairing bearer authentication plus optional gateway.webhook_secret/X-Webhook-Secret; /sop/* and /webhook share the same rate limiter. At least one control must be configured for SOP dispatch, and every configured control must pass. The separate [channels.webhook] alias secrets never authorize these routes |
| Webhook replay protection | Optional X-Idempotency-Key, namespaced per SOP path as well as separately between /sop/* and /webhook. Keys are reserved before dispatch and mean at-most-once attempt, not proof that a prior run started |
| MQTT transport | mqtts:// with use_tls = true for TLS transport |
| Filesystem roots | Broad roots (/, /home, /etc, /var, /proc, /sys, /dev, /tmp) rejected at config validation unless allow_broad_roots; include/exclude globs scope events |
| Filesystem symlinks | Symlink event paths are rejected before any metadata, hash, or content read by default; follow_symlinks = true opts in but still requires the canonical target to resolve inside a watched root |
| Untrusted trigger input | Topic and payload text are capped, normalized, prompt-guard screened, and framed before model context |
| Unsafe trigger block | untrusted_input_guard = "block" refuses unsafe untrusted events with BlockedUnsafe; default warn audits and allows |
| Cron validation | Invalid cron expressions fail closed during parsing and cache build |
| Headless ownership | A headless SOP must declare an owning agent that is configured and enabled; authoring blocks the save and the driver refuses the step rather than borrowing another configured agent. A dashboard-started manual run is refused the same way |
| Headless tool scope | Headless step turns always exclude the SOP control tools and honour the step’s declared scope under step_scope_enforce; child agents spawned from the step inherit those exclusions |
| Headless driver lifetime | Cron and webhook drivers are owned by the daemon generation that started them: drained (30s), then aborted and joined (5s) before a reload rebuilds the config and SOP engine. A driver that reaches no await point in that grace keeps running and is carried into the next generation, logged rather than detached |
| Headless dispatch | Sources with no live driver log run progression instead of auto-executing ExecuteStep |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| SOP never starts from a live source | trigger pattern mismatch or a failing condition | Verify the trigger pattern matches the delivered event; check the condition against the payload |
| SOP started but a step did not execute | a source with no live driver (peripheral, calendar) reached ExecuteStep | Use cron, a webhook, or another live source, run an agent loop for ExecuteStep, or design the run to pause on approvals |
| Cron SOP starts, then fails every run with “no owning agent” | the procedure declares no agent, and a headless run has none to inherit | Set agent in SOP.toml (or on the step) to a configured agent alias |
| Cron SOP fails every run with “which is disabled” | the owning agent is configured with enabled = false | Re-enable the agent, or point agent at one that is enabled |
Dashboard run button returns 422 for a manual SOP | the procedure’s execute steps declare no owner, and a dashboard run has no agent turn to inherit one from | Set agent in SOP.toml (or on the step), or start the procedure from an agent with sop_execute |
| A cron step cannot call a tool it used interactively | the step’s scope excludes it under step_scope_enforce, or it is a SOP control tool | Widen the step’s scope; the SOP control tools stay excluded by design |
| Webhook trigger never fires | exact trigger path mismatch, SOP subsystem unavailable, or authentication rejected | Run zeroclaw daemon with sop.sops_dir configured, match the full request path exactly, and provide the configured bearer/secret headers |
| Peripheral or calendar trigger never fires | event source not wired into the dispatcher | Use a live source (Webhook, MQTT, Filesystem, AMQP) or start the run with sop_execute |
| Cron trigger never fires | maintenance tick not running (no zeroclaw daemon or zeroclaw channel start; standalone gateway start does not run it), sops_dir unset/empty, or maintenance_interval_secs = 0 | Run zeroclaw daemon (or zeroclaw channel start) with sop.sops_dir set to a non-empty value (unset by default; the documented value is shared/sops) and sop.maintenance_interval_secs non-zero (default 60) |
See also
- Syntax: the full
SOP.tomlandSOP.mdformat - How SOPs run
- Channels: Overview: the transport side of MQTT, filesystem, and AMQP