# Testing and Debugging Plugins

> Find out why a Broadcast plugin does not work. Read logs, run draft tests with test data, and look up each error message and its fix.

Source: https://sendbroadcast.net/docs/plugins-testing

> Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

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:

```javascript
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.com` becomes `a***@example.com`. Other personal data is not hidden, so do not log it.
- `console.log` does not reach this page. Use `ctx.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.test` to `person25@example.test`, with IDs 1 to 25.
- Writes are simulated. Nothing changes.
- There is no network. `fetch` answers 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:

1. Go to **Application > Admin API Tokens**.
2. 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](https://sendbroadcast.net/docs/agents-mcp) 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:

```bash
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:

```text
Pushed. Next: broadcast-plugin validate, then broadcast-plugin test.
```

Then check the draft:

```bash
node path/to/plugin_kit/cli/broadcast-plugin.mjs validate
```

```json
{
  "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:

```json
[{ "id": "evt_1", "type": "subscriber.created", "data": { "subscriber_id": 1 } }]
```

Run the hook:

```bash
node path/to/plugin_kit/cli/broadcast-plugin.mjs test --hook onEvent --args args.json
```

```json
{
  "ok": true,
  "result": { "ok": true },
  "error": null,
  "ms": 284.941
}
```

Read the log lines of the test:

```bash
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 ` | 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](https://esbuild.github.io) 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.
```bash
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.forSubscriber` works on the 25 test people only.** Another id answers `not_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: true` sends 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: 1. **Application > Plugins** says the runtime is running. 2. The list shows a version, not **Not deployed**. 3. The channel shows **On**. 4. The hook is in the manifest, and the function name matches it. 5. 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 `` 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 | | ` 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` | | `"" is not an allowed file name.` | Remove hidden files such as `.DS_Store`. Names use letters, digits and `. _ @ + -` | | `manifest.json is not valid JSON: ` | Fix the JSON | | `This is not a valid package file ().` | 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  is already installed.` | You uploaded the same file again | | `Version  is already installed with other code. A published version never changes: use a new version number.` | Raise `version` | | `Version  is older than the installed version . Use rollback to go back to an earlier version.` | Raise `version`, or use **Roll back** | | `This plugin needs Broadcast ; this server runs .` | Change `requires.broadcast`, or upgrade | ### Identity and requires | Message | Fix | |---|---| | `id "" must look like publisher.name (lower case).` | Use `local.my-plugin`. Letters, digits and `-` | | `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 "" 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 "" must be lower case letters, digits and _.` | Rename it. It must start with a letter | | `Setting  must be an object.` | Give it `{ "type": "string" }` | | `Setting : type must be one of string, text, url, integer, boolean, select.` | Use one of these | | `Setting : a select needs options.` | Add an `options` list | ### Permissions | Message | Fix | |---|---| | `permissions must be an object.` | Use `{ }` | | `Unknown permission .` | Check the [permission list](https://sendbroadcast.net/docs/plugins-reference#permissions). Basic things such as `store` need no declaration | | `The permission  does not exist for plugins: no plugin can have it.` | Remove it. No plugin can do this | | `elevated:  is not an elevated permission.` | Use `network.private` here | | `permissions.network must be a list.` | Use `[ ]` | | `network: "" 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 "" must name a setting of type url.` | Declare that setting with `"type": "url"` | ### Hooks | Message | Fix | |---|---| | `hooks must be an object.` | Use `{ }` | | `Unknown hook .` | Check the spelling. `pageData` needs no entry here | | `hooks.events:  is not an event Broadcast publishes.` | Use a name from [Webhooks](https://sendbroadcast.net/docs/webhooks) | | `hooks.schedules: each schedule needs a name (lower case) and a cron expression.` | Add `name` and `cron` | | `hooks.schedules.: "" is not a cron expression.` | Check the five parts | | `hooks.schedules.: runs more often than every 5 minutes.` | Use a longer gap | | `hooks.: "" 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.: description is missing.` | Add `description` | | `hooks.aiTools.: 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.: method must be GET or POST.` | Use upper case | | `hooks.apiEndpoints.: permission must name an API token permission (for example subscribers_read).` | Use a token permission name | | `hooks.publicActions:  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: "" 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: ` | 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  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 ().` | 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](https://sendbroadcast.net/docs/plugins-segment-rules). ### Turning a plugin on | Message | Fix | |---|---| | `Fill in these settings first: .` | 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 |
