Skip to content

Operations & Events

The Fabric catalog has two halves: Operations (things the platform can do) and Events (things the platform emits). Both halves are returned by GET /api/v1/fabric/catalog. This page is a reference for both - what each entry means, how write flags and permissions govern execution, and which event types are always available.

For the broader architecture (how the Negotiator runs Connections, the executor’s two-track model, templating), see The Fabric. For step-by-step Connection authoring, see Connections.


An Operation is a callable capability. It has an id, a human title, a JSON Schema for its input params, a write flag, and optionally a permission and a feature key.

FieldTypePurpose
idstringDotted identifier - native: {module}.{verb}; plugin: plugin.{id}.{verb}
titlestringHuman label shown in the Fabric cockpit
descriptionstringOne-sentence explanation
input_schemaJSON SchemaShape of the params dict the caller supplies
producesstring[]Media types the operation outputs (empty = no payload)
acceptsstring[]Media types the operation can consume as an input artifact from a prior step
permissionstring or nullFreeSDN permission the invoking user must hold; null only for native non-write sinks
writebooleantrue means the operation mutates a live device - it must route through the staged-change pipeline
featurestring or nullStaging pipeline feature key; required (and validated) when write is true
tiernative or pluginTrust tier - governs how the executor runs the op
provider_idstringModule id (native) or plugin id (plugin)

Operation IDs must match ^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$. The registration code enforces four fail-closed invariants at startup - any violation raises a ValueError and prevents the catalog from loading that module:

  • A write operation with no feature → rejected.
  • A write operation with no permission → rejected (a permissionless write would run for any Connection author).
  • A plugin operation whose id does not start with plugin. → rejected.
  • A native operation using the reserved plugin. namespace → rejected.

The write flag and what it means in practice

Section titled “The write flag and what it means in practice”

When write is true, the executor never invokes the op directly. It calls AdapterStagingService.stage_change() and returns a {staged: true, change_id, feature} result. The live device is not touched until an operator explicitly signs off in Pending Changes.

When write is false, the op’s handler runs immediately and its output is available to the next step in the chain.

When an event or step produces a media-type artifact (for example image/jpeg from a camera motion event), the artifact flows to any downstream step that declares accepts: ["image/jpeg"]. The matchmaking is done by GET /api/v1/fabric/connections/suggest?source_event=<type>, which annotates each compatible operation with match: "artifact".

The special sentinel value blob (MEDIA_BLOB) matches any binary type on either side. An operation that declares accepts: ["image/jpeg"] but receives no artifact in the chain fails with ARTIFACT_REQUIRED before staging - the pipeline catches the mismatch early rather than letting a change queue that would 400 at sign-off.


Six modules expose native operations in the catalog today. The table below is authoritative - any additions show up in GET /api/v1/fabric/catalog.

Operation IDModulePermissionOutput
cameras.snapshotcamerascameras.viewimage/jpeg artifact
network.client.listnetworknetwork.viewapplication/json
voip.phone.live_statusvoipvoip.viewapplication/json
firewall.search_alertsfirewallfirewall.view_logsapplication/json
storage.healthstoragestorage.viewapplication/json

These ops execute synchronously when the Negotiator runs the Connection step. Their output is available via {{steps.N.output.*}} in downstream steps.

Write operations (staged, operator sign-off required)

Section titled “Write operations (staged, operator sign-off required)”
Operation IDModulePermissionStaging feature keyAccepts
storage.store_blobstoragestorage.writestorage.store_blobimage/jpeg, blob
hypervisor.vm.snapshothypervisorhypervisor.manage_snapshotsproxmox.snapshot.create-
hypervisor.vm.starthypervisorhypervisor.manage_vmsproxmox.vm.start-
hypervisor.vm.stophypervisorhypervisor.manage_vmsproxmox.vm.stop-
hypervisor.vm.shutdownhypervisorhypervisor.manage_vmsproxmox.vm.shutdown-
hypervisor.vm.reboothypervisorhypervisor.manage_vmsproxmox.vm.reboot-

Hypervisor power verbs are declared as discrete operations (one per verb) because the staging pipeline’s feature key must be static - a single parameterised hypervisor.vm.power op could not route to different applier paths.

The fabric.* namespace provides three always-available sinks. They require no module to be enabled and never touch a live device:

Operation IDPurposeAcceptspermission
fabric.notifyMulti-channel notification (email / Slack / in-app / webhook)application/jsonnull (native non-write sink)
fabric.logStructured audit log line - no side effects-null
fabric.webhookOutbound HTTP call to an external URL (POST by default; PUT/PATCH/GET also supported)application/jsonnull

Because their permission is null, invoking these sinks via POST /api/v1/fabric/operations/{id}/invoke requires the caller to be an unscoped org-admin. Connections authored by an org-admin may include these steps freely.

fabric.webhook is excluded from the AI assistant’s tool projection by design - the AI can never auto-POST org data to an arbitrary URL. Only a human operator wires this step.


You can call a native operation once over HTTP without authoring a Connection:

POST /api/v1/fabric/operations/network.client.list/invoke
Authorization: Bearer <token>
Content-Type: application/json
{"params": {}}
BehaviourDetail
Non-native opReturns 501
Op with a permissionCaller must hold that permission (API key scope is honoured)
Permissionless native sinkCaller must be an unscoped org-admin
Write opReturns {"staged": true, "change_id": "<uuid>", "feature": "..."} - no device is touched
Failed opTransaction rolls back; no partial row

An EventSpec describes a thing the platform emits. A Connection’s source_event field must match an event type in the catalog (or a wildcard pattern - see Connections for pattern syntax).

FieldTypePurpose
event_typestringDotted identifier - native: {domain}.{entity}.{action}; plugin: plugin.{id}.{type}
titlestringHuman label
descriptionstringWhen the event fires and what it carries
payload_schemaJSON SchemaShape of the trigger payload available via {{trigger.*}}
producesstring[]Media types of any artifact the event carries alongside its JSON payload (empty array = pure data event; for example ["image/jpeg"]). Same shape as Operation produces.
tiernative or pluginTrust tier
provider_idstringModule or plugin that emits the event

These seven EventSpecs are always available regardless of which modules are enabled. They cover the two most important lifecycle streams - staged writes and device state - plus the inbound external bridge.

Every adapter write in FreeSDN passes through the staged-change pipeline. These events let you react to that pipeline in real time.

Payload fields: change_id, controller_id, feature, operation, target_id, vendor, status, site_id, applied_at, actor_id.

Event typeWhen it fires
controller.change.stagedA write landed in the pending-changes queue awaiting sign-off
controller.change.appliedA staged write was successfully applied to the device
controller.change.failedA staged write failed at the device or applier

The vendor field discriminates which adapter was involved - use it in a Connection condition to filter by vendor:

{
"source_event": "controller.change.applied",
"conditions": {
"logic": "and",
"conditions": [
{"field": "vendor", "operator": "eq", "value": "omada"}
]
}
}

Payload fields: device_id, site_id, data.name, data.old_status, data.new_status, data.reason.

Event typeWhen it fires
device.status.changedA managed device went online or offline
device.discoveredA new device was discovered or adopted on a controller
device.updatedA managed device’s record changed (rename, re-IP, firmware update, etc.)

Filter to offline events only:

{
"source_event": "device.status.changed",
"conditions": {
"logic": "and",
"conditions": [
{"field": "data.new_status", "operator": "eq", "value": "offline"}
]
}
}
Event typeWhen it fires
ingest.externalEmitted by POST /api/v1/fabric/ingest from any external system

The event type is always ingest.external - an external caller cannot spoof a native event type. Route different external callbacks by adding a condition on the name field:

{
"source_event": "ingest.external",
"conditions": {
"logic": "and",
"conditions": [
{"field": "name", "operator": "eq", "value": "my_callback"}
]
}
}

See The Fabric for the full ingest endpoint reference including rate limits and body caps.


Each module declares additional event sources via its get_emitted_events() hook. The table below covers the current set. Check GET /api/v1/fabric/catalog for the live list.

Event typeArtifact producedNotes
cameras.event.motionimage/jpegMotion detected; artifact is a snapshot handle
cameras.event.personimage/jpegPerson-class detection; artifact is a snapshot handle
camera.status.online-Camera came online
camera.status.offline-Camera went offline
camera.snapshot.capturedimage/jpegSnapshot captured on demand or by schedule

Camera events that produce image/jpeg can be chained into storage.store_blob to persist the snapshot - the Negotiator copies the artifact from the transient broker to the durable store before staging the write, so the bytes survive until an operator signs off.

Event typeNotes
network.vlan.createdA VLAN record was created on a controller
network.vlan.updatedA VLAN record was updated
network.vlan.deletedA VLAN record was deleted
network.wifi.createdA WiFi / SSID record was created
network.wifi.updatedA WiFi / SSID record was updated
network.wifi.deletedA WiFi / SSID record was deleted
Event typeNotes
gateway.sync.completedGateway orchestration sync completed across controllers
gateway.brain.offlineThe gateway brain controller went unreachable
Event typeNotes
storage.pool.degradedA ZFS pool dropped below healthy state
storage.capacity.warningPool capacity crossed the warning threshold
backup.validation.failedA scheduled backup validation run failed
Event typeNotes
ai.budget.warningPer-org AI token budget crossed its warning threshold

The catalog endpoint returns everything in one call:

Terminal window
curl -s https://<freesdn>/api/v1/fabric/catalog \
-H "Authorization: Bearer $TOKEN" | jq '{
op_count: .counts.operations,
event_count: .counts.events,
native_ops: [.operations[] | select(.tier=="native") | .id],
event_types: [.events[] | .event_type]
}'

The response shape:

{
"operations": [...],
"events": [...],
"ai_tools": [...],
"counts": {
"operations": 14,
"events": 22,
"native_operations": 11,
"plugin_operations": 3,
"ai_tools": 9
}
}

The catalog rebuilds on demand with a 3-second TTL cache. A plugin that starts or stops becomes visible within approximately 3 seconds.

To find operations compatible with a given event source - annotated with match (artifact or data) and allowed (whether the current user can author that step):

Terminal window
curl -s "https://<freesdn>/api/v1/fabric/connections/suggest?source_event=cameras.event.motion" \
-H "Authorization: Bearer $TOKEN" | jq '.targets[] | {id: .id, match: .match, allowed: .allowed}'

ActionRequired role / permission
Read the catalogAny authenticated user
Suggest compatible operationsAny org member
Invoke a native op with a permissionCaller must hold that permission; API key scope is the ceiling
Invoke a permissionless native sinkUnscoped org-admin
Invoke a non-native op via /invokeNot possible - returns 501
Author a Connection step (write op)Unscoped org-admin + must hold the step’s permission
Author a Connection step (permissionless sink)Unscoped org-admin
Call POST /api/v1/fabric/ingestAPI key with event:write scope


  • The Fabric - catalog structure, executor model, built-in sink reference, SSRF rules, and env vars
  • Connections - step-by-step guide to authoring event-to-action Connections
  • n8n Integration - community node for bidirectional n8n integration
  • Plugin System - declare custom operations and events from an SDK plugin