Testing and Debugging Plugins
This page shows how to see what your plugin does, how to test it safely and how to fix each error.
Read the logs
Write log lines in your code:
ctx.log.info("Starting the sync."); ctx.log.warn("The CRM answered slowly."); ctx.log.error("The CRM refused the key.");
To read them, go to Application > Plugins, open your plugin and click Logs. The page shows the newest 200 lines, each with its level and hook.
Good to know:
- Lines are kept for 30 days.
- A line can have 2,000 characters.
- Broadcast hides email addresses in logs:
ada@example.combecomesa***@example.com. Other personal data is not hidden, so do not log it. console.logdoes not reach this page. Usectx.log.- A log line is saved when the call ends. It is saved also when your code throws, so log before the risky step.
Test before you deploy: drafts
A draft is a version that is not live. You can run its hooks against test data, and nothing real happens:
- Subscribers are generated:
person1@example.testtoperson25@example.test, with IDs 1 to 25. - Writes are simulated. Nothing changes.
- There is no network.
fetchanswers only from the fixtures you pass. - Settings and secrets are only the ones you pass.
- Your real data is never read.
To use drafts, you need a plugin developer token:
- Go to Application > Admin API Tokens.
- Create a token and turn on Plugin developer. It cannot have other permissions, and it expires within 30 days.
There are two ways to use it: with an AI agent or with the plugin kit.
With an AI agent
Connect your agent to Broadcast as MCP Server explains, with the plugin developer token. The agent then gets these tools:
| Tool | Does |
|---|---|
plugin_api_reference |
Returns the plugin reference for the agent to read |
plugins_list |
Lists plugins and versions |
plugin_draft_create |
Starts a draft |
plugin_draft_write_file, plugin_draft_read_file, plugin_draft_list_files, plugin_draft_delete_file |
Work with the draft’s files |
plugin_draft_validate |
Checks the draft |
plugin_draft_test |
Runs a hook with test data |
plugin_logs |
Reads the log lines of test runs |
plugin_draft_request_deploy |
Sends the draft to an admin for review |
An agent cannot deploy. Only an admin can do that, in Application > Plugins.
With the plugin kit
The plugin kit is a folder with a command line tool. It needs Node 22 or later, and it has no other dependencies.
Warning
The kit is not on npm. npx broadcast-plugin and npm install broadcast-plugin do not work. Download the kit from your own Broadcast server at https://your-broadcast-server/plugin-kit/broadcast-plugin-kit.zip. It is the same version as the server. Unzip it, and run the tool from that folder.
From your plugin folder, run the tool with its path. Set your server and token first:
export BROADCAST_URL=https://your-broadcast-server export BROADCAST_TOKEN=your-plugin-developer-token node path/to/plugin_kit/cli/broadcast-plugin.mjs push
The output lists each file and ends with this:
Pushed. Next: broadcast-plugin validate, then broadcast-plugin test.
Then check the draft:
node path/to/plugin_kit/cli/broadcast-plugin.mjs validate
{ "valid": true, "errors": [], "permissions": [] }
Save the test event in a file named args.json. The arguments are a list, with one item for each argument of the hook:
[{ "id": "evt_1", "type": "subscriber.created", "data": { "subscriber_id": 1 } }]
Run the hook:
node path/to/plugin_kit/cli/broadcast-plugin.mjs test --hook onEvent --args args.json
{ "ok": true, "result": { "ok": true }, "error": null, "ms": 284.941 }
Read the log lines of the test:
node path/to/plugin_kit/cli/broadcast-plugin.mjs logs --limit 5
When you are happy, send it for review with submit.
The kit has other commands too:
| Command | Does |
|---|---|
init <name> |
Makes a new plugin folder. Add --template event-listener, scheduled-sync, settings-page or api-endpoint |
build |
Turns src/main.js into dist/main.js. TypeScript, or code that imports other files, needs esbuild on your PATH |
pack |
Writes the .broadcast-plugin file |
Hook names for a test
Use these names with --hook or in plugin_draft_test. Some are not the same as the name in your code:
| Hook | Test name | Example argument |
|---|---|---|
| Events | onEvent |
{ "id": "evt_1", "type": "subscriber.created", "data": { "subscriber_id": 1 } } |
| Schedules | onSchedule |
{ "name": "morning", "scheduled_for": "2026-11-02T09:00:00Z" } |
| Sequence steps | sequenceStep |
{ "name": "stop_if_tagged", "config": { "tag": "vip" }, "subscriber_id": 1 } |
| Merge tags | mergeTags |
{ "name": "tier", "subscribers": [{ "id": 1, "email": "person1@example.test" }] } |
| Liquid filters | liquidFilter |
{ "name": "format", "items": [{ "input": "5", "args": ["EUR"] }] } |
| Before send | beforeSend |
{ "broadcast_id": 1, "subject": "Hi", "subscribers": [{ "id": 1, "email": "person1@example.test" }] } |
| Public actions | publicAction |
{ "name": "rate", "value": "good", "subscriber_id": 1 } |
| API endpoints | apiEndpoint |
{ "name": "orders", "method": "POST", "query": {}, "body": {} } |
| AI tools | aiTool |
{ "name": "find_tags", "input": { "word": "vip" } } |
| Page data | pageData |
{ "page": 0, "input": {} } |
| Subscriber deleted | onSubscriberDeleted |
{ "subscriber_id": 1, "email": "person1@example.test" } |
Fixtures: fake network answers
A test has no network. To test code that uses fetch, pass a list of answers. A URL that ends in * matches any address that starts the same way.
node path/to/plugin_kit/cli/broadcast-plugin.mjs test --hook onEvent --args args.json \ --fixtures '[{"method":"POST","url":"https://hooks.slack.com/*","status":200,"body":"ok"}]' \ --secrets '{"webhook_url":"https://hooks.slack.com/services/T0/B0/X"}'
If no fixture matches, fetch fails with No fixture for POST https://....
What drafts simulate
ctx.store.forSubscriberworks on the 25 test people only. Another id answersnot_found, as the live store does for a person outside the channel.- A tag or custom field that holds a topic is refused, as in a live call.
- An event with
webhook: truesends nothing. The test writes a log line that says what would go out.
What drafts cannot test
- Pages do not open from a draft.
- Merge tags, filters and links in a real email need a live plugin.
For these, deploy the plugin to a channel that has only your own test subscribers.
Check that it is really running
Use this list when a live plugin does nothing:
- Application > Plugins says the runtime is running.
- The list shows a version, not Not deployed.
- The channel shows On.
- The hook is in the manifest, and the function name matches it.
- The logs show a line for the call.
A plugin that failed too often shows Turned off after repeated errors, with the last error. Deploy a version to turn it on again.
Errors from a call
A failed call in the logs or test output has a code. A refused ctx call throws an error with the same code.
| Code | Meaning | Fix |
|---|---|---|
plugin_error |
Your code threw | Read the message. The line that threw is in your code |
timeout |
The call took longer than its limit, and the process was stopped | Do less in one call. Look for a fetch with no answer |
no_hook |
Broadcast called a hook that your code does not have | Match the function name to the manifest name |
load_failed |
The code did not load | Run the code locally. Look for an error at the top level of the file |
crashed |
The process ended, for example because it ran out of memory | Use less memory. Each process has 64 MB |
busy |
Too many calls waited, or the process took too long to start | Broadcast tries events again. Use fewer calls at once |
forbidden |
The plugin lacks a permission | Add the permission to the manifest, and release a new version |
not_found |
No such record in this channel | Handle it. The person may be gone |
invalid |
The arguments are wrong | Read the message |
rate_limited |
A limit was reached | Slow down, or try at the next run |
call_ended |
Your hook ended while a call was still open | Add the missing await |
These codes come from Broadcast, and they are not the plugin’s fault. They do not count toward the 20 failures: safe_mode, not_enabled, runtime_unavailable, busy and incompatible.
Messages you may see
| Message | Fix |
|---|---|
The plugin may not use tags.add. |
Add "tags": "write" to the manifest |
Reading past the first 100 subscribers needs subscribers:export. |
Add "subscribers": ["read", "export"], or read less |
Creating subscribers needs subscribers:create. |
Add "subscribers": ["write", "create"] |
A bulk call needs an idempotency_key. |
Add one. Build it from the run, not a random value, so that a retry uses the same key |
A bulk call takes at most 1000 items. |
Split the list |
The plugin declares no setting webhook_url. |
Add the setting to the manifest |
Requires net access to "example.com:443", run again with the --allow-net flag |
Your manifest does not declare that host. Ignore the flag: add the host to permissions.network |
"order.placed" is not an event this plugin declares. |
Add it to the top-level events key |
the bundle must export default definePlugin({...}) |
Start your plugin with export default definePlugin({ |
the result is not JSON: ... |
Return plain data, with no functions or loops |
Errors from an upload
Broadcast shows these when you upload a plugin or validate a draft. The text in <angle brackets> changes.
The package
| Message | Fix |
|---|---|
Choose a .broadcast-plugin file. |
Pick a file |
The package has no manifest.json. |
Run zip from inside the plugin folder |
The package has no dist/main.js. |
Put your code at that exact path |
<path> is not in an allowed folder (ui/, assets/, src/, vendor/). |
Remove the file, or move it. Allowed at the top: manifest.json, dist/main.js, README.md, LICENSE, package.json, deno.json, deno.lock |
"<path>" is not an allowed file name. |
Remove hidden files such as .DS_Store. Names use letters, digits and . _ @ + - |
manifest.json is not valid JSON: <detail> |
Fix the JSON |
This is not a valid package file (<detail>). |
The file is not a zip file |
The package is too large. |
Keep the zip file under 5 MB |
The package has too many files. |
Use at most 500 files |
The version
| Message | Fix |
|---|---|
Version <x> is already installed. |
You uploaded the same file again |
Version <x> is already installed with other code. A published version never changes: use a new version number. |
Raise version |
Version <x> is older than the installed version <y>. Use rollback to go back to an earlier version. |
Raise version, or use Roll back |
This plugin needs Broadcast <range>; this server runs <version>. |
Change requires.broadcast, or upgrade |
Identity and requires
| Message | Fix |
|---|---|
id "<id>" must look like publisher.name (lower case). |
Use local.my-plugin. Letters, digits and - |
id <id>: a package that is not signed by the plugin directory must use an id that starts with local. |
Your own plugin must use local. |
name is missing. |
Add name |
version "<x>" is not a semantic version (for example 1.2.0). |
Use three numbers |
requires.pluginApi must be one of 1. |
Use "pluginApi": "1" as a string |
requires.broadcast must be a version range, for example ">=3.0.0 <4.0.0". |
Copy that range |
Settings
| Message | Fix |
|---|---|
settings must be an object. |
Use { }, not a list |
Setting name "<name>" must be lower case letters, digits and _. |
Rename it. It must start with a letter |
Setting <name> must be an object. |
Give it { "type": "string" } |
Setting <name>: type must be one of string, text, url, integer, boolean, select. |
Use one of these |
Setting <name>: a select needs options. |
Add an options list |
Permissions
| Message | Fix |
|---|---|
permissions must be an object. |
Use { } |
Unknown permission <name>. |
Check the permission list. Basic things such as store need no declaration |
The permission <name> does not exist for plugins: no plugin can have it. |
Remove it. No plugin can do this |
elevated: <name> is not an elevated permission. |
Use network.private here |
permissions.network must be a list. |
Use [ ] |
network: "<host>" is not an allowed host. Use a public host name, for example api.example.com or api.example.com:8443. |
Do not use a URL with https://, an IP address, localhost or a private name |
network: fromSetting "<name>" must name a setting of type url. |
Declare that setting with "type": "url" |
Hooks
| Message | Fix |
|---|---|
hooks must be an object. |
Use { } |
Unknown hook <name>. |
Check the spelling. pageData needs no entry here |
hooks.events: <name> is not an event Broadcast publishes. |
Use a name from Webhooks |
hooks.schedules: each schedule needs a name (lower case) and a cron expression. |
Add name and cron |
hooks.schedules.<name>: "<cron>" is not a cron expression. |
Check the five parts |
hooks.schedules.<name>: runs more often than every 5 minutes. |
Use a longer gap |
hooks.<key>: "<name>" must be lower case letters, digits and _. |
Rename it |
hooks.aiTools: each tool must be an object with a name and a description. |
Use an object, not a string |
hooks.aiTools.<name>: description is missing. |
Add description |
hooks.aiTools.<name>: input_schema must be a JSON schema of type object. |
Use "type": "object" |
hooks.apiEndpoints: each endpoint must be an object. |
Use an object |
hooks.apiEndpoints.<name>: method must be GET or POST. |
Use upper case |
hooks.apiEndpoints.<name>: permission must name an API token permission (for example subscribers_read). |
Use a token permission name |
hooks.publicActions: <action> must be an object with a name of lower case letters, digits and _. |
Use { "name": "vote" }, not a string |
Pages
| Message | Fix |
|---|---|
ui.pages: each page must be an object. |
Use an object |
ui.pages: slot must be one of nav, subscriberPanel, dashboardWidget. |
Use one of these |
ui.pages: each page needs a title. |
Add title |
ui.pages: "<path>" must be an .html file in ui/ in the package. |
Check the path, and pack the ui folder |
Code
These start with dist/main.js:.
| Message | Fix |
|---|---|
dist/main.js does not parse: <detail> |
Fix the syntax error |
dist/main.js has no `export default definePlugin({...})`. |
Add it |
The bundle imports another module. Bundle every dependency into dist/main.js. |
Remove import, or bundle with esbuild |
The bundle re-exports another module. Bundle every dependency into dist/main.js. |
Remove export ... from |
The bundle loads code at run time with import(). This is not allowed. |
Remove it |
import.meta is not available to plugins. |
Remove it |
eval() is not allowed., Function() is not allowed., new Function() is not allowed. |
Remove it |
Line <n> is longer than 2000 characters. Minified code is not allowed. |
Do not minify. An admin must be able to read the code |
The bundle check failed (<reason>). |
The server could not run the check. Ask the admin to check the plugin runtime |
Events and segment rules have their own messages. They name the field and say what it needs. See Plugin Events and Segment Rules.
Turning a plugin on
| Message | Fix |
|---|---|
Fill in these settings first: <names>. |
Fill in the required settings |
Deploy a version of this plugin before you enable it. |
Deploy it |
This plugin has secret settings, and this server has no encryption keys (ACTIVE_RECORD_ENCRYPTION_*). |
Ask the server admin to set the encryption keys |