The Fabric
The Fabric is FreeSDN’s universal app-interconnect. Every module that declares capabilities - cameras, hypervisor, storage, network, firewall, VoIP - publishes those capabilities into a single tier-tagged catalog of Operations (things you can do) and EventSpecs (things a module emits). You author Connections that wire an event source to a step chain. The in-process Negotiator runs those chains on the live event bus. No code required.
This page covers:
- the catalog vocabulary (tiers, operations, events)
- the built-in sinks (
fabric.notify/fabric.log/fabric.webhook) - the executor’s two-track read-vs-write model and why writes are always staged
- how inbound (
/fabric/ingest) and outbound (fabric.webhook) bridge external systems - the key env vars and production deployment constraints
To learn how to author a Connection step-by-step, see Connections.
The catalog
Section titled “The catalog”GET /api/v1/fabric/catalog returns a snapshot of everything the platform can do or can emit, plus a count breakdown:
GET /api/v1/fabric/catalogAuthorization: Bearer <token>The catalog has three sections:
| Section | What it contains |
|---|---|
operations | Callable capabilities - each has an id, title, input schema, permission, and a write: bool flag |
events | Trigger sources - each has an event_type, payload schema, and optional produces (media type of a referenced artifact) |
ai_tools | Read-only projection of the AI assistant’s tool registry - what the AI can actually call |
The catalog is rebuilt on demand with a 3-second TTL cache. A plugin that starts or stops becomes visible within approximately 3 seconds.
Browse it in the UI under Fabric → Catalog, or call the API directly and pipe to jq.
Trust tiers
Section titled “Trust tiers”Every operation and event is tagged with a tier that governs how it runs:
| Tier | Source | Trust model |
|---|---|---|
native | First-party in-tree modules | Full trust; read ops execute directly; write ops route through staging |
plugin | SDK plugins | SDK-bounded; permission-declared; namespaced plugin.{id}.*; write ops are categorically refused |
A native operation whose id starts with plugin. is rejected at registration time (and vice versa). The namespace guard is enforced in code, not convention.
Operation IDs
Section titled “Operation IDs”Native operation IDs follow {module}.{verb} (for example cameras.snapshot). Plugin operation IDs follow plugin.{plugin_id}.{verb}. Both must match ^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$.
Event types
Section titled “Event types”Native event types follow {domain}.{entity}.{action} (for example device.status.changed). Plugin event types follow plugin.{plugin_id}.{type}.
Native operations in the catalog
Section titled “Native operations in the catalog”Six modules declare native operations today. The table below lists them with their write flag and the permission the caller must hold:
| Operation ID | Module | Write? | Permission required |
|---|---|---|---|
cameras.snapshot | cameras | no | cameras.view |
network.client.list | network | no | network.view |
voip.phone.live_status | voip | no | voip.view |
firewall.search_alerts | firewall | no | firewall.view_logs |
storage.health | storage | no | storage.view |
storage.store_blob | storage | yes | storage.write |
hypervisor.vm.snapshot | hypervisor | yes | hypervisor.manage_snapshots |
hypervisor.vm.start | hypervisor | yes | hypervisor.manage_vms |
hypervisor.vm.stop | hypervisor | yes | hypervisor.manage_vms |
hypervisor.vm.shutdown | hypervisor | yes | hypervisor.manage_vms |
hypervisor.vm.reboot | hypervisor | yes | hypervisor.manage_vms |
Hypervisor power verbs are declared as discrete operations (one per verb) because the feature key in the staging pipeline must be static; a single parameterised hypervisor.vm.power op with an action field could not route correctly.
Native event sources
Section titled “Native event sources”The Fabric ships platform-wide events that are always available, plus per-module events declared by each module’s get_emitted_events() hook.
Platform-wide (built-in)
Section titled “Platform-wide (built-in)”Staged-change lifecycle - payload includes change_id, controller_id, feature, operation, target_id, vendor, status, site_id, applied_at, actor_id:
| Event type | When it fires |
|---|---|
controller.change.staged | A write landed in the pending-changes queue |
controller.change.applied | A staged write was applied to the device |
controller.change.failed | A staged write failed at the device or applier |
Device lifecycle - payload includes device_id, site_id, data.{name,old_status,new_status,reason}:
| Event type | When it fires |
|---|---|
device.status.changed | A managed device went online or offline |
device.discovered | A new device was discovered or adopted |
device.updated | A managed device’s record changed (rename, re-IP, firmware…) |
Inbound bridge:
| Event type | When it fires |
|---|---|
ingest.external | Emitted by POST /fabric/ingest - see Inbound bridge below |
Module-declared events (selected)
Section titled “Module-declared events (selected)”| Event type | Module | Artifact produced |
|---|---|---|
cameras.event.motion | cameras | image/jpeg snapshot handle |
cameras.event.person | cameras | image/jpeg snapshot handle |
camera.status.online / camera.status.offline | cameras | - |
camera.snapshot.captured | cameras | image/jpeg |
network.vlan.created / .updated / .deleted | network | - |
network.wifi.created / .updated / .deleted | network | - |
gateway.sync.completed | firewall | - |
gateway.brain.offline | firewall | - |
storage.pool.degraded / storage.capacity.warning | storage | - |
backup.validation.failed | backup | - |
ai.budget.warning | ai | - |
When an event produces a media-type artifact (for example image/jpeg), any downstream Connection step that declares it accepts that type receives the artifact reference automatically. The GET /api/v1/fabric/connections/suggest?source_event=cameras.event.motion endpoint returns a pre-annotated list of compatible operations.
Three built-in sinks
Section titled “Three built-in sinks”The Fabric ships three always-available sink operations under the fabric.* namespace. They require no module to be enabled and never touch a live device:
fabric.notify
Section titled “fabric.notify”Dispatches a multi-channel notification - email, Slack, in-app, webhook - through the same dispatch_notifications helper used everywhere else in the platform.
| Param | Required | Notes |
|---|---|---|
channels | yes | JSONB channel config (same shape as notification settings) |
title | no | Truncated to 200 characters |
body | no | Truncated to 4,000 characters |
fabric.log
Section titled “fabric.log”Records a structured Fabric log line. No side effects. Use it to verify Connection wiring before adding a real action.
| Param | Required | Notes |
|---|---|---|
message | no | Free-form string |
fabric.webhook
Section titled “fabric.webhook”POSTs the chain’s current payload to an external URL. This is the outbound half of the external bridge.
| Param | Required | Notes |
|---|---|---|
url | yes | Must be a public destination (see SSRF rules below) |
method | no | POST / PUT / PATCH / GET - default POST |
payload | no | Defaults to the trigger event payload if omitted |
headers | no | Key-value dict added to the request |
fabric.webhook is deliberately excluded from the AI assistant’s tool projection. The AI can never auto-POST org data to an arbitrary URL - only a human operator wires this step.
If FABRIC_WEBHOOK_SIGNING_SECRET is set, every outbound POST also carries:
X-Fabric-Signature: sha256=<hmac>over the exact bodyX-Fabric-Timestampwith a replay window
The response body is capped at 16 KiB and flows into the step output so a subsequent step can branch on the result. The request timeout is 30 seconds.
Read vs write routing
Section titled “Read vs write routing”The executor applies a strict two-track model based on the operation’s write flag:
Connection fires → Negotiator evaluates steps │ ├── write=false → _execute_native_read │ op.handler(ctx) runs directly → result available to next step │ └── write=true → _execute_native_write (STAGE ONLY) AdapterStagingService.stage_change() → pending_changes row in the DB → result = {staged: true, change_id, feature} ↓ operator clicks Apply in Pending Changes ↓ live device is mutatedThe Fabric never force-applies a write. There is no flag, environment variable, or Connection setting that causes a write op to bypass staging. The dual gate (ADAPTER_READ_ONLY=false AND force=true) must be explicitly satisfied by a human operator on the Pending Changes screen.
What the executor checks before staging a write
Section titled “What the executor checks before staging a write”- A DB session is available (
NO_DBotherwise). controller_idis present in the step params and is a valid UUID (NO_TARGET/BAD_TARGET).- The target controller belongs to the Connection’s own organisation - checked via a
Controller → Sitejoin (CROSS_TENANT_TARGET). This is enforced even whencontroller_idis templated from an untrusted event payload. - If the op
acceptsa media-type artifact but no artifact is present in the chain, the step fails withARTIFACT_REQUIREDrather than staging a change that would 400 at sign-off. - If the event carries a binary artifact (for example a camera snapshot), that artifact is copied from the transient broker to the durable store now, and only a small reference (durable token + SHA-256 + size) is embedded in the staged payload. Sign-off can be hours or days later; the bytes must outlive the transient broker’s 1-hour TTL.
Plugin operations
Section titled “Plugin operations”Plugin write operations are categorically refused (PLUGIN_WRITE_FORBIDDEN). Device mutations only ever originate from a native, operator-authored path. Plugin read operations run behind a 30-second timeout, with output bounded at 256 KiB.
Calling POST /api/v1/fabric/operations/{operation_id}/invoke with a non-native operation returns 501.
Connection authoring and the permission gate
Section titled “Connection authoring and the permission gate”Before you can create a Connection, you must be an unscoped org-admin. Each step in a Connection is validated at authoring time and again at execution time:
- A step whose operation has
permission: nullis allowed only if it is a native, non-write sink (the built-infabric.notify/fabric.log/fabric.webhooktrio). - A step with a declared permission requires the author to currently hold that permission. A Connection executes with its author’s permissions - if the author is later deactivated or moves to a different org, the permission gate re-checks on every firing and denies.
Non-author org members can list and view Connections, but the step params (which may contain webhook URLs, Authorization headers, notification targets) are replaced with {"__redacted__": "hidden - author-only"} in list and get responses. Only the operation_id is always visible.
Templating between steps
Section titled “Templating between steps”Step params can reference values from the trigger payload or from earlier steps using {{dotted.path}} syntax:
{ "operation_id": "fabric.webhook", "params": { "url": "https://n8n.example.com/webhook/freesdn", "payload": { "device": "{{trigger.device_id}}", "prev_status": "{{trigger.data.old_status}}", "snapshot_url": "{{steps.0.output.url}}" } }}Context shape:
{ "trigger": { "<trigger event payload>" }, "steps": [ {"output": {}, "artifact": null, "success": true}, "..." ]}Inbound: /fabric/ingest
Section titled “Inbound: /fabric/ingest”Any external system can push a callback into the Fabric:
POST /api/v1/fabric/ingestX-API-Key: <org-api-key>Content-Type: application/json
{"name": "my_callback", "payload": {"result": "ok", "id": "abc123"}}| Property | Value |
|---|---|
| Required permission | event:write on the API key |
| Emitted event type | Always ingest.external (never caller-controlled) |
| Name sanitisation | Lowercased, [^a-z0-9_-] stripped, max 64 chars |
| Body cap | 64 KiB → 413 if exceeded |
| Per-org rate limit | 120 requests per 60-second window (cluster-wide via Valkey) |
| Response | 202 {"accepted": true, "event_type": "ingest.external", "name": "my_callback"} |
The event type is always ingest.external - an external caller cannot spoof a native event type. Route different callbacks by adding a condition on name in a Connection. For example, a Connection with source_event: ingest.external and conditions: name = "my_callback" fires only for that name.
Cluster-wide guards
Section titled “Cluster-wide guards”The Negotiator uses Valkey (via the platform’s redis:// connection) for two cluster-wide guards:
| Guard | Mechanism | Failure mode |
|---|---|---|
| At-most-once per firing | SET fabric:fired:{conn}:{event} NX EX 300 | Fail-open - without Redis, each gunicorn worker fires independently |
| Per-connection cooldown | SET fabric:cooldown:{conn} NX EX {cooldown_seconds} | Falls back to in-process counter |
Endpoints reference
Section titled “Endpoints reference”Catalog and invoke
Section titled “Catalog and invoke”| Method | Path | Purpose | Permission |
|---|---|---|---|
| GET | /api/v1/fabric/catalog | Full catalog: operations + events + AI-tool projection + counts | authenticated |
| POST | /api/v1/fabric/operations/{operation_id}/invoke | Invoke a single native operation over HTTP (writes stage; returns staged_change_id) | per-op permission or unscoped org-admin for permissionless sinks |
invoke returns 501 for any non-native operation. A permissionless native sink (fabric.notify / fabric.log / fabric.webhook) requires an unscoped org-admin. A write call returns {staged: true, change_id: "..."} - no live device is touched.
Connection CRUD
Section titled “Connection CRUD”| Method | Path | Purpose | Permission |
|---|---|---|---|
| POST | /api/v1/fabric/connections | Create a Connection (201) | unscoped org-admin |
| GET | /api/v1/fabric/connections | List Connections; step params redacted for non-authors | any org member |
| GET | /api/v1/fabric/connections/suggest | Ops compatible with a source_event - annotated with match and allowed | any org member |
| GET | /api/v1/fabric/connections/{id} | Get one Connection (params redacted for non-authors) | any org member |
| PATCH | /api/v1/fabric/connections/{id} | Update a Connection | unscoped org-admin |
| DELETE | /api/v1/fabric/connections/{id} | Delete (204) | unscoped org-admin |
| GET | /api/v1/fabric/connections/{id}/runs | Run audit rows (default limit 50, max 200) | any org member |
| POST | /api/v1/fabric/connections/{id}/test | Run once with an explicit payload; writes stage; records a run | unscoped org-admin |
Inbound bridge
Section titled “Inbound bridge”| Method | Path | Purpose | Permission |
|---|---|---|---|
| POST | /api/v1/fabric/ingest | Emit ingest.external onto the org bus (202) | event:write |
Environment variables
Section titled “Environment variables”| Variable | Default | Purpose |
|---|---|---|
FABRIC_ARTIFACT_DURABLE_DIR | /data/fabric_artifacts | Where staged-write blobs are stored until operator sign-off |
FABRIC_WEBHOOK_ALLOWED_HOSTS | "" (empty) | Comma-separated hostnames/IPs the webhook op may reach even if private/LAN/tailnet |
FABRIC_WEBHOOK_SIGNING_SECRET | "" (empty) | When set, every outbound webhook POST carries an HMAC signature |
What the Fabric does NOT do
Section titled “What the Fabric does NOT do”These are not limitations to work around - they are deliberate safety contracts:
- Fabric writes are never auto-applied. Every write op, regardless of how it is triggered (Connection, AI assistant, direct
/invokecall, or automationfabric.operationaction), only stages a pending change. An operator must click Apply. - Plugin write ops are refused.
PLUGIN_WRITE_FORBIDDENis returned; plugin code can never touch a live device through the Fabric. - The AI assistant cannot call
fabric.webhook. The operation is excluded from the AI bridge by design - the assistant can never auto-POST org data to an arbitrary URL. - Templating cannot evaluate expressions. Only dotted-path lookups. No arithmetic, no conditionals, no function calls.
- The Negotiator and artifact broker run in the API process, not in Celery. The broker writes artifact files to the API container’s filesystem. A Celery worker in a separate container cannot access those files directly.
Next steps
Section titled “Next steps”- Connections - step-by-step guide to authoring event-to-action Connections
- n8n - community node (
n8n-nodes-freesdn) for bidirectional n8n integration - Plugin System - declare custom operations and events via SDK plugins
- Marketplace - install signed community plugins from the marketplace