Plugin Troubleshooting
Manifest, codegen, host, and runtime failures — what each one means and how to fix it.
Plugin failures are deliberately loud and fail closed. Almost every one falls into four families: the manifest is invalid, composition is invalid or stale, authorization was denied, or a contribution hit its declared safe fallback.
Manifest errors
definePlugin and parsePluginManifest throw PluginManifestError with one issue per problem: { code, path, message }, where path is a JSON pointer-ish string like $.contributes.crons[0].timeoutMs. validatePluginManifest returns the same issues without throwing.
| Issue code | Typical cause |
|---|---|
missing | A required field is absent — most often $.flag for a plugin that contributes, uses storage, or requests llm:invoke; or $.capabilities missing the capability a declared bucket requires |
invalid_type | Wrong JavaScript type, or an accessor property where data was required |
invalid_format | id is not lowercase kebab-case ≤ 64 chars, version is not semver, a capability is not domain:action, an env var is not UPPER_SNAKE, or an export path is not a safe relative package export |
duplicate | A repeated capability or required env var |
unknown_field | A field or contribution bucket the platform does not know |
too_many_items | Over a ceiling: 64 capabilities, 64 env vars, 256 entries per bucket, 64 settings fields |
accessor_not_allowed | A getter/setter where the validator requires plain data — manifests are inspected as data and never invoked |
Frequent first-time mistakes:
- "must declare
<capability>when<nouns>are contributed" — add the capability constant tocapabilities. - "
$.flagmust be a plain object when …" — every contribution bucket, both storage capabilities, andllm:invokerequire an explicitflag. llmBudgetrejected —dailyUsdmust be > 0, ≤ 1 000 000, and expressible with at most six decimal places.- Cron rejected —
intervalMinutesmust be 15 … 40 320 andtimeoutMs1 000 … 300 000. - "must be listed in
$.flag.requiredEnvVars" — a feedback webhook'ssignature.secretEnvVarmust also appear inflag.requiredEnvVars. Without the secret the route can only answer503, and listing it makes the plugin un-enableable until an operator sets it instead.
Codegen errors
PluginCodegenError carries a code plus a details list.
| Code | Meaning and fix |
|---|---|
config_invalid | plugins.config.ts is not a literal package-name list. It is parsed as data, never evaluated — keep it a plain array |
dependency_missing | "Bundled plugin <name> must be installed as a root dependency or optionalDependency." Install it at the workspace root from the registry, then re-run add |
dependency_provenance | Installed metadata, bun.lock integrity, or realpath containment did not match. Aliases and git/URL/file/workspace sources fail closed by design |
package_load_failed | The package's manifest entry could not be imported. Check that the default export is the definePlugin manifest and that importing it has no side effects |
conditional_manifest_export | The manifest export is condition-dependent, so Bun, Convex, Nuxt SSR, and the browser build could resolve different manifests. Use one condition-independent root export |
invalid_manifest | The loaded manifest failed validation — see the manifest table above |
component_export_invalid / contribution_export_invalid | A declared exportPath is not an exact, existing, condition-independent package export |
composition_invalid | Duplicate package names, duplicate manifest ids, or a contribution graph the host rejects (unknown or terminal agent-step anchor, duplicate kind, insertion cycle, unsafe lifecycle edge) |
generated_files_stale | --check found missing or out-of-date output. Run bun run plugins:codegen and commit the result with the config change |
generated_path_unsafe | A generated target or a parent directory is a symlink. Output is written through an exclusive temp file and an atomic rename and refuses to follow links |
direct_plugin_import | Core source imports a configured plugin package outside the generated composition files — including via a Node/Bun loader or a repository alias. Route the import through the generated registry |
repository_inventory_invalid | The boundary scan could not build its bounded file inventory, or the repository exceeds the scan's file safety limit |
repository_config_invalid | A repository alias configuration (tsconfig paths, bundler aliases) could not be read safely, or alias discovery exceeded its file limit |
source_invalid | A source file could not be parsed while scanning for the import-boundary rule |
workspace_not_found | The command was run outside the Owlat workspace |
Cannot find module '@owlat/plugin-kit' when invoking the CLI or codegen from source means the contracts have not been built yet. Run bun run plugins:prepare once (the plugins:codegen / plugins:check scripts do it for you).
The same applies to running packages/plugin-codegen or packages/plugin-cli tests directly with vitest. Those packages deliberately do not rebuild the contracts themselves: packages/plugin-kit/dist is a shared artifact rebuilt with tsup --clean, so a package-local rebuild would wipe it underneath whatever else the run is compiling. Turbo owns that ordering instead (test depends on @owlat/plugin-kit#build), and bun run lint:build-graph fails if a package script starts rebuilding it again.
That guard also covers packages/plugin-kit itself — only its build and its publish-only prepack / postpack hooks may drive the tsup --clean, so the package smoke packs with --ignore-scripts and asserts it left dist/ untouched — and the repository root, where plugins:prepare is the one sanctioned entry point: any other root script reaching into another workspace directory with --cwd fails the guard unless it is added to ROOT_CWD_ALLOWLIST in scripts/check-build-graph.ts.
Host denials
PluginHostError is raised at the enforcement boundary. Its code says exactly which link in the chain failed.
| Code | Meaning | Fix |
|---|---|---|
capability_not_declared | The manifest never requested this capability | Add it to the manifest and redeploy — a grant cannot widen a manifest |
capability_not_granted | Declared, but the operator has not granted it | Enable the plugin and approve its capabilities in Settings → Plugins |
plugin_disabled | The feature flag is off | Enable the flag |
required_environment_missing | A flag.requiredEnvVars variable is absent | Set it in the deployment environment; enablement is blocked until every one is present |
environment_check_failed / feature_check_failed | The host could not evaluate the environment or the flag | Infrastructure problem — the operation fails closed, which is intended |
invalid_capability_grant / invalid_manifest_snapshot / invalid_contribution | Host configuration or composition is inconsistent | Re-run codegen; if it persists, the composition and the deployment have drifted |
untrusted_output_rejected | A plugin returned a non-string on a text boundary, or the scrubber failed | Return a plain string from the module |
invalid_untrusted_text_policy | A host adapter named an invalid policy | Host bug, not a plugin bug |
"My contribution isn't running"
Work down this list — it is the same order the host checks:
- Is the package in
plugins.config.tsand is the generated composition current (bun run plugins:check)? - Was the deployment rebuilt after the config change? Bundled composition is a build-time artifact.
- Is the plugin's feature flag enabled?
- Were its capabilities approved when it was enabled? Disabling clears the grant record.
- Are all
flag.requiredEnvVarspresent in the deployment environment? - Does the manifest declare the capability the bucket requires?
- For nav and settings entries: does the target section exist and is it feature-on? Does another entry already own the same
href? Dedup is by href, first-registered-wins, and core registers first.
"The contribution ran but nothing changed"
That is usually a safe fallback doing its job. Each bucket has a declared direction:
| Symptom | Likely cause |
|---|---|
| Replies keep going to human review | A plugin gate objected, timed out, threw, returned malformed output, or was denied. Gates fail closed |
| Drafts look like the built-in ones | The plugin draft strategy was denied, timed out, failed, or produced oversized/injection-like output — one fallback to default |
| An agent step vanished from the pipeline | Authorization was denied at the last moment. The walker skips the contribution and resumes the unchanged core continuation |
| An automation step keeps retrying | The plugin step returned failed or threw. The walker owns retries and the circuit breaker |
| A hook value is ignored | The response failed strict validation, arrived late, or the circuit is open. Check the hook delivery log's fallback reason |
Tier-2 hook failures
Look up the fixed fallback reason in the hook delivery log:
| Reason | What to check |
|---|---|
blocked_ssrf / redirect_refused | The endpoint resolves to a private/internal address, or it redirects. Both are refused — publish a public https endpoint that answers directly |
timeout | The endpoint took longer than 5 s |
signature_missing / signature_mismatch | Your response is unsigned or signed wrong. Sign the response canonical string, echo the request nonce, use v1=<hex> |
stale_response | The response's signed timestamp is outside the 30 s tolerance — check clock skew |
response_too_large | The response exceeded 64 KiB |
invalid_json / invalid_response | The body is not exactly the shape for that hook kind. Extra keys, wrong types, and empty strings are rejected |
capability_denied | The operator did not grant this hook kind to this app |
circuit_open | Five consecutive failures tripped the breaker; it retries one trial call after 60 s |
app_disabled / app_revoked | Operator action — revoked is terminal |
secret_unavailable | The sealed secret could not be opened. Usually a changed INSTANCE_SECRET — rotate the app's secret |
Tier-3 job failures
| Symptom | Cause |
|---|---|
| No plugin jobs appear | Expected today: worker:enqueue is reserved and no host enqueue adapter is shipped |
worker_timeout | The job exceeded its host-clamped wall clock; the whole process group was killed |
worker_failed after several attempts | The job exhausted its clamped attempt budget (≤ 5) and terminated as failed |
Jobs go back to queued on their own | Lease reclaim after a worker crash or restart |
| A job cannot read a credential | Correct and intentional. The job environment is stripped of every ambient credential; mediate it through the host |