SIGNALS Documentation
API Reference

Plugin manifest reference

Complete signals.yaml reference — every section validated by ManifestValidator, exact failure messages, and worked example manifests including Xero Sync.

Overview

Parsed into App\Sdk\Manifest\PluginManifest and validated by App\Sdk\Manifest\ManifestValidator. Rule keys below are the keys on ManifestValidationException::$errors.

Normalised sections: package, name, version, signals_version, requires, conflicts, permissions, data_access, tables, hooks, slots, settings, network, operations, nav, pages, datatables, notifications, channel_drivers, events, tools.

Cross-link: field allowlists → Data models; runtime enforcement → Data access.

Validate interactively: the Manifest validator & builder runs any pasted signals.yaml through this exact validator (and builds new manifests section by section), reporting failures under the rule keys below.


File / parse errors (before validator)

Key Message
manifest.file Manifest file not found: {$path}
manifest.file Manifest file could not be read: {$path}
manifest.parse Manifest YAML could not be parsed: + exception message
manifest.parse Manifest YAML must parse to a mapping.

Identity

Field Required Rule key
package yes identity.required / identity.package.format
name yes identity.required
version yes identity.required / identity.version.semver
signals_version yes identity.required / identity.signals_version.constraint
icon no identity.icon.format
Key Exact message
identity.required Manifest identity is missing required field(s): {comma-separated}.
identity.package.format Package [{$manifest->package}] must be a Composer name in vendor/name format.
identity.version.semver Version [{$manifest->version}] must be a valid semantic version (e.g. 1.2.0).
identity.signals_version.constraint signals_version [{$manifest->signals_version}] is not a well-formed version constraint.
identity.icon.format icon [{$manifest->icon}] must be a kebab-case flux icon name (e.g. bolt, puzzle-piece).

signals_version is constrained against config('plugins.sdk_version') (currently 1.0.0) at lifecycle time.

icon is the plugin's identity icon — a kebab-case flux/heroicon name (e.g. bolt, puzzle-piece). It is shown in the Plugin Manager and anywhere the plugin is referenced (dropdowns, titles), and serves as the fallback when nav.icon is omitted.


requires / conflicts

Maps of package => constraint. Same validation; rule keys differ.

Key Exact message
requires.constraint / conflicts.constraint Dependency package [{$package}] must be a Composer name in vendor/name format.
same Dependency constraint [{$constraint}] for [{$package}] is not well-formed.
requires:
  acme/helper: "^1.0"
conflicts:
  rival/xero-legacy: "*"

permissions

List of resource.action strings. Every key must be prefixed with {vendor}. where vendor is the first Composer segment of package (e.g. signals/xero-sync → signals.).

Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/

Key Exact message
permissions.namespace Permission entries must be non-empty resource.action strings.
permissions.namespace Permission [{$permission}] must be namespaced with vendor prefix [{$prefix}].
permissions.namespace Permission [{$permission}] must use resource.action format under the vendor prefix.

Wiring via $registrar->permission($key, …) is gated: key must appear here.


data_access

Per-entity allowlist for Signals::read() / Signals::update(). Allowed operations: read, write, delete.

Key Exact message
data_access.malformed data_access[{$index}] is missing a non-empty entity.
data_access.malformed data_access[{$index}] must declare at least one operation.
data_access.malformed data_access[{$index}] operation [{$operation}] is invalid; allowed: read, write, delete.
data_access.malformed {$path} contains an empty field name. (when validating fields[])
data_access.read_fields data_access[{$index}].read_fields must be a non-empty list when present.
data_access.read_fields {path} contains an empty field name.
data_access.write_fields data_access[{$index}].write_fields must be a non-empty list when present.
data_access.write_fields {path} contains an empty field name.
data_access.fields data_access[{$index}] has no field list for [{$operation}]; declare fields[] or {$operation}_fields.

Field list resolution (ManifestDataAccess::fieldsFor):

  • When read_fields is present (non-null) it fully defines the read allowlist.
  • When write_fields is present it fully defines the write allowlist.
  • Otherwise fields[] applies for that operation.

Custom-field declaration matching (enforced at runtime by PluginDataAccess, not the YAML field-name grammar):

  • Nested custom fields are written under attribute key custom_fields (flat map).
  • Nested key x is allowed when the effective list declares custom_fields (wholesale) or custom_fields.x.
  • Any undeclared top-level or nested key rejects the whole update.

Known entities: accounts, rentals, invoices, catalogue_items — see Data models.


tables

Key Exact message
tables.prefix Table [{$table}] must start with plugin_ and use snake_case (e.g. plugin_acme_log).

Pattern: /^plugin_[a-z][a-z0-9_]*$/


hooks

Key Exact message
hooks.malformed hooks[{$index}] requires non-empty name and type.

Fields: name, type (e.g. event, filter), optional priority (runtime default 50). The validator does not enum-check type against HookType.


slots

Key Exact message
slots.malformed slots[{$index}] requires non-empty slot and component.

Optional: priority, permission.


settings

Allowed types: string, integer, boolean, enum, json.

Key Exact message
settings.malformed settings[{$index}] requires non-empty key, type, and label.
settings.malformed settings[{$index}] type [{$setting->type}] is invalid; allowed: string, integer, boolean, enum, json.
settings.malformed settings[{$index}] of type enum requires a non-empty options list.
settings.malformed settings[{$index}] rules must be a list of non-empty strings.

Accepted on the DTO but not validated by ManifestValidator: encrypted, default (runtime in PluginSettingsManager).

Storage group: plugin_ + package with / and - → _ (e.g. signals/xero-sync → plugin_signals_xero_sync). See Plugin settings.


network

Bare hostnames only — no wildcards, schemes, paths, or ports.

Key Exact message
network.wildcard Network allowlist entries must be concrete hostnames.
network.wildcard Network allowlist entry [{$host}] must not contain wildcards; declare a concrete host.
network.wildcard Network allowlist entry [{$host}] must be a bare hostname (no scheme, path, or port).
network.wildcard Network allowlist entry [{$host}] is not a valid hostname.

operations

Names for Signals::operation(). Format resource.action.

Key Exact message
operations.malformed operations[{$index}] must be a non-empty operation name string.
operations.malformed operations[{$index}] [{$operation}] must use resource.action format (e.g. activities.complete).

Must also be mapped in PluginOperationRegistry. Seeded today: activities.create, activities.complete, favourites.toggle. See Plugin Operation Registry.


Top-level nav group (one per plugin) shown in the app header after Finance, with a matching group sidebar on the plugin's UI pages. Items reference declared pages[] keys and are wired at runtime via PluginRegistrar::navGroup().

nav:
  group: Field Ops            # header group label
  icon: map                   # kebab-case flux icon name
  items:
    - key: jobs               # slug, unique within the plugin
      label: Jobs
      page: jobs              # references pages[].key
      permission: acme.fieldops.view   # optional; gates header item, sidebar item, and page
      icon: briefcase         # optional; falls back to the group icon
Key Exact message
nav.malformed nav requires a non-empty group label.
nav.malformed nav icon is required unless a top-level plugin icon is declared as the fallback.
nav.malformed nav icon [{$icon}] must be a kebab-case flux icon name (e.g. map, puzzle-piece).
nav.malformed nav must declare at least one item.
nav.items nav.items[{$index}] key [{$key}] must be a lowercase slug.
nav.items nav.items[{$index}] requires a non-empty label.
nav.items nav.items[{$index}] key [{$key}] is declared more than once; item keys must be unique.
nav.items nav.items[{$index}] icon [{$icon}] must be a kebab-case flux icon name.
nav.page_ref nav.items[{$index}] page [{$page}] does not reference a declared pages[] key.

pages

Config-driven UI pages rendered by core components only — no plugin views. Each page renders at /plugins/{vendor}/{name}/{key} (the Composer package split into two path segments). Section data is supplied at runtime by PluginRegistrar::page($key, $dataProvider).

pages:
  - key: jobs
    title: Jobs
    description: Field jobs overview
    layout: console           # only 'console' in v1
    sections:
      - type: stat-grid       # rows of core stat cards
      - type: table           # columns + rows, values escaped
      - type: html            # trusted HTML string (same trust model as Html slots)
      - type: datatable       # a declared datatables[] key, rendered by the shared DataTable
        datatable: acme.fieldops.jobs
Key Exact message
pages.malformed pages[{$index}] key [{$key}] must be a lowercase slug (e.g. jobs, sync-status).
pages.malformed pages[{$index}] requires a non-empty title.
pages.malformed pages[{$index}] layout [{$layout}] is invalid; allowed: console.
pages.key_unique pages[{$index}] key [{$key}] is declared more than once; page keys must be unique.
pages.sections pages[{$index}] must declare at least one section.
pages.sections pages[{$index}].sections[{$sectionIndex}] type [{$type}] is invalid; allowed: stat-grid, table, html, datatable.
pages.sections pages[{$index}].sections[{$sectionIndex}] of type [datatable] must name a declared datatables[] key.
pages.sections pages[{$index}].sections[{$sectionIndex}] references undeclared datatable [{$key}].

A datatable section takes no data from the page's provider — the registered definition is its content. It is skipped for a viewer without the definition's permission, so sibling sections keep rendering.

See Plugin UI for the runtime wiring and section data shapes.


datatables

Listing tables the plugin may register with PluginRegistrar::datatable(). Keys are global — page sections and slots reference a table by key alone — so they must be dotted and vendor-prefixed exactly like permissions. See DataTable SDK and Plugin Data Table Registry.

datatables:
  - key: acme.fieldops.jobs
    permission: acme.fieldops.view   # optional here, mandatory on the definition
    description: Open field jobs.
Key Exact message
datatables.malformed datatables[{$index}] key [{$key}] must use dotted resource.name format (e.g. acme.jobs.table).
datatables.malformed datatables[{$index}] key [{$key}] must be namespaced with vendor prefix [{$prefix}].
datatables.malformed datatables[{$index}] permission must be a non-empty permission key when declared.
datatables.key_unique datatables[{$index}] key [{$key}] is declared more than once; datatable keys must be unique.

The declaration is the gate: datatable() refuses a key that is not listed here, and when permission is declared the registered definition must gate on exactly that permission. A permission is mandatory on the definition in every case.


notifications

Types the plugin may send via Signals::notify(). Keys must be vendor-prefixed like permissions. Channels (when present) are a non-empty subset of core channels (database, mail, broadcast) or logical channels declared by the same plugin in channel_drivers.

notifications:
  - key: acme.sync.failed
    label: Sync Failed
    group: Acme
    channels: [database, mail, slack]
    description: Fired when a sync fails.
Key Exact message
notifications.malformed notifications[{$index}] requires non-empty key and label.
notifications.malformed notifications[{$index}] channels must be a non-empty subset of core channels (database, mail, broadcast) or channels declared in channel_drivers[] when present.
notifications.malformed notifications[{$index}] channel [{$channel}] is invalid; allowed: {allowed list}.
notifications.namespace Notification [{$key}] must be namespaced with vendor prefix [{$prefix}].
notifications.namespace Notification [{$key}] must use resource.action format under the vendor prefix.

Wiring via $registrar->notification($key, …) is gated: key must appear here. See Plugin UI.


channel_drivers

Notification channel drivers the plugin may register via $registrar->channelDriver($key, $class). Keys must be vendor-prefixed like permissions. Guide: Plugin channel providers.

channel_drivers:
  - key: acme.slack_webhook
    channel: slack
    label: Slack Incoming Webhook
    config_fields:
      - webhook_url
      - default_channel
Key Exact message
channel_drivers.malformed channel_drivers[{$index}] requires non-empty key, channel, and label.
channel_drivers.malformed channel_drivers[{$index}] channel [{$channel}] must be a lowercase slug (e.g. slack, teams).
channel_drivers.malformed channel_drivers[{$index}] channel [{$channel}] collides with a core notification channel.
channel_drivers.malformed channel_drivers[{$index}] config_fields must be a non-empty list when present.
channel_drivers.malformed channel_drivers[{$index}] config_fields must be a list of non-empty strings.
channel_drivers.key_unique channel_drivers[{$index}] key [{$key}] is declared more than once; driver keys must be unique.
channel_drivers.namespace Channel driver [{$key}] must be namespaced with vendor prefix [{$prefix}].
channel_drivers.namespace Channel driver [{$key}] must use resource.action format under the vendor prefix.

Wiring via $registrar->channelDriver($key, …) is gated: key must appear here.


tools

Optional MCP / CLI tools. Names must be vendor-prefixed like permissions. Wired at runtime via PluginRegistrar::tool(). See Plugin MCP / CLI tools and Plugin Tool Registry.

Rule key When it fires
tools.malformed tools[{$index}] missing name, title, action, or permission
tools.namespace Tool name missing vendor prefix / invalid format
tools.name_unique Duplicate tool name
tools.action Action is not a fully-qualified class name
tools.permission Permission is neither declared on the plugin nor a registered core permission
tools.surfaces Empty or unknown surface (allowed: mcp, cli)
tools.args Arg missing name/type or unknown type (string, integer, number, boolean)

events

Plugin-owned EventRegistry names for event() / webhookEvent() / Signals::emit(). Names must be vendor-prefixed (e.g. acme.sync.completed).

events:
  - name: acme.sync.completed
    label: Sync Completed
    webhook: true
Key Exact message
events.malformed events[{$index}] requires a non-empty name.
events.namespace Event [{$name}] must be namespaced with vendor prefix [{$prefix}].
events.namespace Event [{$name}] must use dotted resource.action format under the vendor prefix (e.g. acme.sync.completed).

Wiring via $registrar->event($name, …) / webhookEvent($name) is gated: name must appear here; webhook visibility also requires webhook: true. See Plugin events.


Example A — minimal valid

package: acme/hello
name: Acme Hello
version: 0.1.0
signals_version: "^1.0"

permissions:
  - acme.hello.view

slots:
  - slot: dashboard.widgets
    component: signals.stat-card
    priority: 90
    permission: acme.hello.view

settings:
  - key: greeting
    type: string
    label: Greeting
    default: Hello

network: []

Example B — Xero Sync (annotated)

Source: plugins/signals/xero-sync/signals.yaml.

package: signals/xero-sync          # identity.package — Composer name
name: Xero Sync
version: 0.1.0                      # SemVer
signals_version: "^1.0"             # constraint vs config plugins.sdk_version

permissions:                        # vendor prefix signals.
  - signals.xero.sync
  - signals.xero.view

data_access:
  - entity: accounts
    read_fields:                    # asymmetric read allowlist
      - name
      - email                       # virtual field (primary email sync)
      - custom_fields.xero_contact_id
    write_fields:                   # write only the Xero id CF
      - custom_fields.xero_contact_id
    operations:
      - read
      - write
  - entity: invoices
    read_fields:
      - number
      - total
      - currency_code
      - account_id
      - custom_fields.xero_invoice_id
    write_fields:
      - custom_fields.xero_invoice_id
    operations:
      - read
      - write

operations: []                      # no Signals::operation() names

tables:
  - plugin_xero_sync_log            # must match migration-created table

hooks:
  - name: invoice.issued            # EventRegistry + AuditableEvent
    type: event
    priority: 50
  - name: account.updated
    type: event
    priority: 50

slots:
  - slot: dashboard.widgets
    component: signals.stat-card    # core Blade component
    priority: 70
    permission: signals.xero.view

settings:
  - key: client_id
    type: string
    label: Xero Client ID
    rules: [nullable, string, max:255]
  - key: client_secret
    type: string
    label: Xero Client Secret
    encrypted: true                 # leave-blank-to-keep on save
  - key: tenant_id
    type: string
    label: Xero Tenant ID
    rules: [nullable, string, max:255]
  - key: sync_direction
    type: enum
    label: Sync Direction
    options: [push, pull, both]
    default: push

network:                            # exact hosts for PluginHttpClient
  - api.xero.com
  - identity.xero.com

Example C — recipes

UI-only plugin

package: acme/dashboard-tip
name: Dashboard Tip
version: 1.0.0
signals_version: "^1.0"

permissions:
  - acme.dashboard_tip.view

slots:
  - slot: dashboard.widgets
    component: signals.stat-card
    permission: acme.dashboard_tip.view

settings:
  - key: tip_text
    type: string
    label: Tip text
    default: Remember to confirm returns

network: []

Data-sync plugin

package: acme/crm-push
name: CRM Push
version: 1.0.0
signals_version: "^1.0"

permissions:
  - acme.crm_push.sync

data_access:
  - entity: accounts
    read_fields: [name, email, custom_fields.external_id]
    write_fields: [custom_fields.external_id]
    operations: [read, write]

hooks:
  - name: account.updated
    type: event
    priority: 50

network:
  - api.partner.example

settings:
  - key: api_token
    type: string
    label: API token
    encrypted: true

Import/export extension plugin

Import/export registrar seams (importableModel, exportableModel, importTransform, planTemplate) are not on this branch. Declare data_access + hooks as above for sync-style work today; see Import/export plugins for the merge note and Coming soon.

Machine-readable specification (JSON Schema)

The manifest format is published as a versioned JSON Schema — the public standard for signals.yaml — at:

/schemas/signals-plugin-manifest.v1.json

On a running Signals instance the schema is served statically (no authentication) at https://{your-host}/schemas/signals-plugin-manifest.v1.json, and it lives in the repository at public/schemas/signals-plugin-manifest.v1.json.

Use it to validate manifests in editors (# yaml-language-server: $schema=...), CI pipelines, or LLM toolchains. Two rules cannot be expressed in JSON Schema and are enforced only by the framework validator: the vendor-prefix requirement on permission keys (permissions.namespace) and the wiring ⊆ declaration rule. php artisan signals:plugin check {package} runs the authoritative validator and reports failures using the same rule keys the schema documents.

For the authoritative validator over HTTP — no checkout required — POST /docs/tools/manifest-validate with {"yaml": "..."} (public, rate-limited; response shape documented in the LLM guide), or use the interactive Manifest validator & builder.