SIGNALS Documentation
API Reference

Building a Signals plugin with an LLM

Self-contained instructions an agent can follow to scaffold, validate, and test a Signals Composer plugin against App\Sdk.

How to use this page

Copy everything under Agent prompt block into an agent session. Point the agent at a Signals checkout on the Plugin SDK branch. Prefer reading the linked docs under /docs/development/plugin-* when unsure.


Agent prompt block

You are building a Signals framework plugin. Follow these rules exactly.

### Context
- Plugins are Composer packages with package-root signals.yaml + composer.json.
- Entry class extends App\Sdk\PluginBase and implements register(PluginRegistrar $registrar): void.
- Public stability boundary is App\Sdk\* — do not touch core Eloquent models except via Signals::read/update/operation or PluginContext helpers.
- Local discovery supports plugins/{name}/ and vendor-nested plugins/{vendor}/{name}/.
- UI: no plugin-shipped Blade views. Declare slots; register core component names via registrar->slot().
- Slot families: {entity}.detail.header_actions (16 entities), {entity}.detail.tabs (rental|product|fleet_vehicle), {entities}.index.actions (catalogue_item_groups|credit_notes|payments), boards (dispatch.board.actions|returns.board.actions|scanning.console.actions), notifications.panel.actions, dashboard.widgets, modals.global. Full list: SlotRegistry docblock.
- Modals: declare slot modals.global (component signals.plugin-modal for the config-driven Flux shell, or Html). Open from any surface: $dispatch('modal-show', { name: '...' }).
- Pages: declare nav (one group: group, icon, items[{key,label,page,permission?,icon?}]) + pages[] ({key,title,description?,layout: console, sections[{type: stat-grid|table|html}]}). Wire registrar->navGroup(label, icon, items) — must match declaration exactly — and registrar->page(key, dataProvider) returning section data keyed by section index. Renders at /plugins/{vendor}/{name}/{page}.
- Palette: registrar->paletteCommands(fn (): array => [{key,label,url,icon?,group?,keywords?,permission?}]) — no manifest section; permission must be null or a declared permissions[] key. navItem(show_in_palette: true) also lands in the palette.
- Recents: Signals::recent(package, {label, url (must start with /), icon?, type?}) — plugin must be enabled; appears in the global Recent menu.
- Notifications: declare notifications[] ({key (vendor-prefixed), label, group?, channels? ⊂ database|mail|broadcast or logical channels from this plugin's channel_drivers[], description?}); wire registrar->notification(key, definition); send Signals::notify(package, key, users, {title (required), body?, url? (internal path), icon?}).
- Channel providers: declare channel_drivers[] ({key (vendor-prefixed), channel (slug, not database/mail/broadcast), label, config_fields?}); implement ChannelDriver; wire registrar->channelDriver(key, class). Tenant config lives on ChannelProvider rows. Guide: /docs/development/plugin-channel-providers. Reference: plugins/signals/slack-webhook.
- Provided events: declare events[] ({name (vendor-prefixed), label?, webhook?}); wire registrar->event(name, {plugin?, webhook?, ...}) or registrar->webhookEvent(name) (needs manifest webhook: true); fire Signals::emit(package, name, payload). Any plugin (including the declaring one) may hook the name — call event() before the hook() that subscribes to it.
- Tables: only plugin_* (snake_case). Migrations run through PluginMigrationGuard.
- Wiring ⊆ declaration: permissions, settings keys, hook names, slot+component pairs, nav group/items, page keys, notification keys, channel driver keys, event names must appear in signals.yaml before registrar calls.
- Network: concrete hostnames only in network[] (no wildcards).
- Data: entities accounts|rentals|invoices|catalogue_items only. Use read_fields/write_fields or fields[]. Custom fields: custom_fields or custom_fields.x. Whole-update rejection on undeclared keys.
- Hooks: PluginContext is the first handler argument. Prefer HookType::Event. Event/Filter names must exist in EventRegistry (or register via registrar->event() first).
- PluginContext helpers mirror the facade: read/update/setting/http/storage/recent/notify/emit.
- Lifecycle CLI: php artisan signals:plugin {list|check|install|enable|disable|remove} [package]
- Enable is next-boot effective (no hot-boot).
- Import/export registrar methods importableModel/exportableModel/importTransform/planTemplate are NOT on this branch.

### Folder structure
plugins/{vendor}/{name}/
  composer.json   # name, autoload.psr-4, extra.signals.plugin = FQCN
  signals.yaml
  database/migrations/   # optional, plugin_* tables only
  src/{Name}Plugin.php
  src/... handlers/clients/models as needed

### Minimal composer.json shape
{
  "name": "vendor/name",
  "autoload": { "psr-4": { "Vendor\\Name\\": "src/" } },
  "extra": { "signals": { "plugin": "Vendor\\Name\\NamePlugin" } }
}

### Minimal signals.yaml identity
package, name, version (SemVer), signals_version (e.g. "^1.0")
Optional sections: requires, conflicts, permissions, data_access, operations, tables, hooks, slots, settings, network, nav, pages, notifications, events, tools (MCP/CLI — see plugin-mcp-cli-tools)

### PluginBase skeleton
namespace Vendor\Name;
use App\Sdk\PluginBase;
use App\Services\Plugins\PluginRegistrar;
class NamePlugin extends PluginBase {
  public function register(PluginRegistrar $registrar): void { /* … */ }
  // optional: boot, install, enable, disable, uninstall, onUpdate
}

### Registrar surface (shipped — call only what you need)
permission, ability, setting(SettingsDefinition), webhookEvent, event, notification, hook,
slot, tool, navItem, navGroup, page, paletteCommands,
documentType, documentResolver, extendDocumentResolver, pdfDriver, seedDocumentTemplates,
channelDriver, recipientResolver, deliveryTrackingResolver, registerFilter,
rateStrategy, rateModifier, demandSource, shortageResolver, costApportionment,
discountPredicate, discountSource, dealPriceValidator, opportunityValidator,
onFlightcaseBeforePack … onFlightcaseAfterTransfer

### Signals facade (mediated runtime APIs)
Signals::setting, Signals::http, Signals::storage, Signals::read, Signals::update,
Signals::operation, Signals::recent, Signals::notify, Signals::emit

### Step order
1) Write signals.yaml (identity + only sections you will wire).
2) Write composer.json with matching package name + plugin FQCN.
3) Implement PluginBase subclass register().
4) Add plugin_* migrations if needed; declare tables[].
5) Add hooks/slots/settings/network/nav/pages/notifications/events as required; keep wiring ⊆ declaration. Register event() before any hook() on the same name.
6) Tests: use tests/Support/Plugins/{MakesPluginManifests,FakesPluginHooks,InstallsFixturePlugins}; Http::fake / Storage::fake / Notification::fake for I/O.
7) Run: php artisan signals:plugin check vendor/name
8) install + enable; restart PHP process; verify admin Plugins page, slots, and /plugins/{vendor}/{name}/{page} pages.

### Hard rules
- plugin_ tables only; never migrate core tables.
- No plugin views; config-driven slots only.
- No Eloquent on core models; use Signals / PluginContext facade.
- No query builder / list API on the data facade.
- Permissions must use {vendor}. prefix from Composer package name.
- Do not invent registrar methods that are not in PluginRegistrar.php.

### Verification commands
php artisan signals:plugin list
php artisan signals:plugin check {package}
php artisan signals:plugin install {package}
php artisan signals:plugin enable {package}
php artisan test --compact tests/Feature/...  # your plugin tests
# No checkout? Validate the manifest over HTTP (public, 30 req/min per IP):
# POST {host}/docs/tools/manifest-validate with JSON {"yaml": "<signals.yaml contents>"}
# -> {"valid": bool, "errors": {"rule.key": "message"}} (422 + "parse_error" if the YAML cannot parse)

### Canonical docs (read when blocked)
/docs/development/plugin-sdk
/docs/development/plugin-getting-started
/docs/development/plugin-folder-structure
/docs/development/plugin-manifest
/docs/development/plugin-hooks
/docs/development/plugin-events
/docs/development/plugin-ui
/docs/development/plugin-data-access
/docs/development/plugin-data-models
/docs/development/plugin-registries-and-resolvers
/docs/development/plugin-examples
/docs/development/plugin-coming-soon   # aspirational only

Human notes

  • Kitchen-sink reference (every surface): plugins/signals/slots-demo/.
  • HTTP + custom-fields reference: plugins/signals/xero-sync/.
  • Minimal fixture: tests/Fixtures/plugin-packages/acme-example/.
  • After enabling, hooks/slots register only on the next application boot.

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.

Programmatic validation over HTTP

Every Signals instance also serves the authoritative validator as a public, unauthenticated JSON endpoint (the same engine behind the interactive Manifest validator & builder):

POST {host}/docs/tools/manifest-validate
Content-Type: application/json

{"yaml": "package: acme/hello\nname: Acme Hello\nversion: 0.1.0\nsignals_version: \"^1.0\"\n"}

Responses:

Case Status Body
Manifest valid 200 {"valid": true, "errors": {}}
Validation rules failed 200 {"valid": false, "errors": {"tables.prefix": "Table [accounts] must start with plugin_ …"}}
YAML cannot parse 422 {"valid": false, "errors": {}, "parse_error": "Manifest YAML could not be parsed: …"}
Missing/empty yaml field 422 {"message": "…"}
Input over 64 KB 413 {"message": "…"}
Rate limit exceeded 429 —

Error keys are the manifest reference rule keys. Limits: 30 requests/min per IP, 64 KB per manifest; input is never stored.