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 codeTypical cause
missingA 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_typeWrong JavaScript type, or an accessor property where data was required
invalid_formatid 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
duplicateA repeated capability or required env var
unknown_fieldA field or contribution bucket the platform does not know
too_many_itemsOver a ceiling: 64 capabilities, 64 env vars, 256 entries per bucket, 64 settings fields
accessor_not_allowedA 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 to capabilities.
  • "$.flag must be a plain object when …" — every contribution bucket, both storage capabilities, and llm:invoke require an explicit flag.
  • llmBudget rejecteddailyUsd must be > 0, ≤ 1 000 000, and expressible with at most six decimal places.
  • Cron rejectedintervalMinutes must be 15 … 40 320 and timeoutMs 1 000 … 300 000.
  • "must be listed in $.flag.requiredEnvVars" — a feedback webhook's signature.secretEnvVar must also appear in flag.requiredEnvVars. Without the secret the route can only answer 503, and listing it makes the plugin un-enableable until an operator sets it instead.

Codegen errors

PluginCodegenError carries a code plus a details list.

CodeMeaning and fix
config_invalidplugins.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_provenanceInstalled metadata, bun.lock integrity, or realpath containment did not match. Aliases and git/URL/file/workspace sources fail closed by design
package_load_failedThe 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_exportThe 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_manifestThe loaded manifest failed validation — see the manifest table above
component_export_invalid / contribution_export_invalidA declared exportPath is not an exact, existing, condition-independent package export
composition_invalidDuplicate 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_unsafeA 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_importCore 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_invalidThe boundary scan could not build its bounded file inventory, or the repository exceeds the scan's file safety limit
repository_config_invalidA repository alias configuration (tsconfig paths, bundler aliases) could not be read safely, or alias discovery exceeded its file limit
source_invalidA source file could not be parsed while scanning for the import-boundary rule
workspace_not_foundThe command was run outside the Owlat workspace
Run the CLI from a prepared 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.

CodeMeaningFix
capability_not_declaredThe manifest never requested this capabilityAdd it to the manifest and redeploy — a grant cannot widen a manifest
capability_not_grantedDeclared, but the operator has not granted itEnable the plugin and approve its capabilities in Settings → Plugins
plugin_disabledThe feature flag is offEnable the flag
required_environment_missingA flag.requiredEnvVars variable is absentSet it in the deployment environment; enablement is blocked until every one is present
environment_check_failed / feature_check_failedThe host could not evaluate the environment or the flagInfrastructure problem — the operation fails closed, which is intended
invalid_capability_grant / invalid_manifest_snapshot / invalid_contributionHost configuration or composition is inconsistentRe-run codegen; if it persists, the composition and the deployment have drifted
untrusted_output_rejectedA plugin returned a non-string on a text boundary, or the scrubber failedReturn a plain string from the module
invalid_untrusted_text_policyA host adapter named an invalid policyHost bug, not a plugin bug

"My contribution isn't running"

Work down this list — it is the same order the host checks:

  1. Is the package in plugins.config.ts and is the generated composition current (bun run plugins:check)?
  2. Was the deployment rebuilt after the config change? Bundled composition is a build-time artifact.
  3. Is the plugin's feature flag enabled?
  4. Were its capabilities approved when it was enabled? Disabling clears the grant record.
  5. Are all flag.requiredEnvVars present in the deployment environment?
  6. Does the manifest declare the capability the bucket requires?
  7. 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:

SymptomLikely cause
Replies keep going to human reviewA plugin gate objected, timed out, threw, returned malformed output, or was denied. Gates fail closed
Drafts look like the built-in onesThe plugin draft strategy was denied, timed out, failed, or produced oversized/injection-like output — one fallback to default
An agent step vanished from the pipelineAuthorization was denied at the last moment. The walker skips the contribution and resumes the unchanged core continuation
An automation step keeps retryingThe plugin step returned failed or threw. The walker owns retries and the circuit breaker
A hook value is ignoredThe 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:

ReasonWhat to check
blocked_ssrf / redirect_refusedThe endpoint resolves to a private/internal address, or it redirects. Both are refused — publish a public https endpoint that answers directly
timeoutThe endpoint took longer than 5 s
signature_missing / signature_mismatchYour response is unsigned or signed wrong. Sign the response canonical string, echo the request nonce, use v1=<hex>
stale_responseThe response's signed timestamp is outside the 30 s tolerance — check clock skew
response_too_largeThe response exceeded 64 KiB
invalid_json / invalid_responseThe body is not exactly the shape for that hook kind. Extra keys, wrong types, and empty strings are rejected
capability_deniedThe operator did not grant this hook kind to this app
circuit_openFive consecutive failures tripped the breaker; it retries one trial call after 60 s
app_disabled / app_revokedOperator action — revoked is terminal
secret_unavailableThe sealed secret could not be opened. Usually a changed INSTANCE_SECRET — rotate the app's secret

Tier-3 job failures

SymptomCause
No plugin jobs appearExpected today: worker:enqueue is reserved and no host enqueue adapter is shipped
worker_timeoutThe job exceeded its host-clamped wall clock; the whole process group was killed
worker_failed after several attemptsThe job exhausted its clamped attempt budget (≤ 5) and terminated as failed
Jobs go back to queued on their ownLease reclaim after a worker crash or restart
A job cannot read a credentialCorrect and intentional. The job environment is stripped of every ambient credential; mediate it through the host