Plugins and skills
A manifest the validator refused, a checksum that did not match, a contribution that never appeared, a skill that was skipped.
This page covers the two ways the office is extended: a plugin that will not load, is refused, loads without doing anything, or keeps running after you disabled it; and a skill file that is skipped, or loads and is never offered to a turn. For the general shape of both, see Building a plugin and Writing a skill.
What these failures have in common
Both extensions fail quietly by design, and for the same reason. A malformed skill file costs you that file, not the boot. A plugin whose manifest is wrong is recorded against its directory and skipped, not thrown. The office is trying to stay up, so the failure lands somewhere you have to look for it — a warning line, a plugin record with a status of error, or a contributions count of zero.
So the first move in almost every symptom below is the same: find the record rather than the behaviour. A plugin's own record carries its identifier, version, source, enabled flag, status, error text and a contribution count per kind. A skill problem is either a line in the log at scope skills or a selection decision inside a turn. Neither is visible from the outside, which is why "it is installed but nothing happens" has an answer and not a mystery.
One warning before anything else, because it is the sentence most likely to waste an afternoon. A code plugin runs inside the orchestrator's process with the orchestrator's authority. The permission list in its manifest is enforced for two capabilities — registering a tool needs tools, subscribing to the event stream needs events — and is a disclosure for the rest; it is not a boundary, and the tools the plugin registers are not confined by the host. Only install one from somewhere you trust. See Known gaps.
A plugin directory is in place and nothing appears
What you see. A directory that looks like a plugin exists under the configured plugins location, and the console lists no such plugin. The log may carry <directory>: no plugin.json in the directory. or <directory>: <field>: <message>; ... at scope plugins.
What it means
Plugin discovery scans the two configured directories one level deep, and treats each immediate subdirectory as a plugin that must contain a plugin.json at its root. A manifest sitting directly in the plugins directory, or a plugin nested two levels down, is not a plugin as far as discovery is concerned. Each candidate is read inside a failure container, so a manifest that cannot be read or does not validate is recorded against the directory rather than stopping the scan.
Two directories are involved and it is worth keeping them apart. The configured plugins directory holds the bundled set that ships with the checkout; the install directory holds everything downloaded from a marketplace. They are separate precisely so a download can never be confused with something that came with the repository. A plugin found twice is ignored the second time with a warning naming the directory it skipped.
What to check
- Confirm the shape. The manifest must be at the plugin directory's own root and named exactly
plugin.json. - Confirm which directory you are in. Bundled plugins and marketplace installs are two different paths, each with its own environment variable.
- Read the directory-level warning. It carries either the missing-manifest message, an invalid-JSON message, or the validator's own field-by-field problems joined with semicolons.
- Check for a duplicate id. A second directory claiming an id that is already loaded is skipped, with a warning naming both the id and the ignored directory.
- Ask the console to rescan after adding a directory by hand, rather than restarting, if the plugin refresh command is available on the socket.
The manifest was rejected
What you see. A validator report of field and message pairs. Real examples: id: must be lowercase reverse-dns style with at least two segments, e.g. "dev3d.cost-guard"., apiVersion: is required; this host implements "1"., apiVersion: targets API "2", but this host implements "1"., permissions: unknown permission(s): teleport.
What it means
The validator reads the manifest and collects problems rather than stopping at the first one, so a single run tells you everything that is wrong. Each problem names a field path and a message, and the whole set is joined into the error text recorded against the plugin. The rules worth knowing by heart are the identifier shape and the API version: an identifier must be lowercase reverse-DNS style with at least two segments and at most 64 characters, and the API version must be one this host implements, which is why the message quotes the host's own value back at you.
Permissions are validated against a closed set of ten names, and an unrecognised one is a validation problem rather than a warning, because a consent screen that lists a permission the host does not understand would be a lie. Related and easy to trip over: the version field is shape-checked only. It is never compared with anything during validation, and the one comparison helper in the source is explicitly documented as not a semantic-version implementation.
What to check
- Fix every reported problem, not the first. The validator lists them all, and a second run costs more than reading the whole report.
- Check the identifier against the shape rule rather than against another plugin's id; two segments is a minimum, not a convention.
- Check the API version against the quoted host value. The message tells you the value this host implements, so there is nothing to look up.
- Check the permission names against the documented set and remove anything the host does not know.
- Check
settingsis where you think it is. It is a top-level manifest field, not one of the eight contribution keys, and putting it undercontributesproduces a dropped contribution rather than a validation error.
checksum mismatch during an install
What you see. checksum mismatch: the marketplace published 3f9a1c77b0e2d4a5… but the bundle hashes to 91d0ee31ac47bb02…., and nothing is left installed.
What it means
When a marketplace entry publishes a digest, the host hashes the downloaded archive and refuses to install on any difference. The comparison happens before anything is unpacked, and a failed install leaves nothing behind because extraction happens into a staging directory that is only moved into place on success. A mismatch means the bytes that arrived are not the bytes the catalogue described: a truncated download, a rebuild that was published without updating the digest, or a bundle that was replaced after the catalogue was written.
The same install path has a set of neighbouring refusals, each with its own message, and knowing the family saves time: the plugin is not in that marketplace, it is already installed, an upgrade was requested for something that ships with the office, the marketplace offers no newer version than the one installed, the download returned an HTTP status, the archive has no plugin directory, the bundle declares a different identifier than the one requested, or the archive breaks one of the bundle limits.
What to check
- Compare the two digests in the message. The published one is truncated to sixteen characters and so is the computed one, which is enough to see that they differ and not enough to debug the content.
- Download the bundle yourself and hash it. If your hash matches the published digest, the problem is in transit rather than at the source.
- Check the marketplace entry is the current one. A catalogue that was written before a rebuild will publish a stale digest forever.
- Check the archive shape if the refusal is not a checksum. A bundle must be a gzipped tar with the plugin at its root or in a single wrapping directory, and the archive reader refuses links, unsupported entry types, absolute paths and paths that escape the extraction directory.
- Do not install around it. Extracting the bundle by hand and dropping it into the install directory skips the digest entirely, which is the one check that ties the bytes to what the catalogue promised.
Installing plugins is disabled
What you see. Installing plugins is disabled. Set DEV3D_ALLOW_PLUGIN_INSTALL=true to allow running code downloaded from a marketplace.
What it means
This is the install gate and it is off by default, deliberately. Installing a plugin means running somebody else's code inside the orchestrator's own process, so the decision belongs to whoever owns the machine rather than to whoever is sitting at the console. The gate is a real switch rather than a silent failure: the marketplace screen explains that installing is disabled on this installation instead of offering a button that does nothing.
It is also environment-only for the life of the process. There is no settings row for it, because whether code from the network may run in this process is not a UI preference, and it is read once at boot.
The fix. Set DEV3D_ALLOW_PLUGIN_INSTALL=true in the environment and restart, having decided first that this process should run downloaded code. A restart is part of the fix rather than a habit: the gate is read once, at boot.
What to check
- Confirm you want to run downloaded code in this process. That is the whole content of the decision.
- Set the variable and restart. Only the literal value true enables it, and it is read at boot.
- Check the plugin you want is not already bundled. The three plugins that ship with the checkout live in the bundled directory and need no install; an upgrade request against one of them is refused with its own message.
- Remember what unloading does and does not do. Disabling a code plugin withdraws its contributions and calls its deactivate hook, but the module stays resident until the process restarts.
The plugin loaded but failed to activate
What you see. A log line at scope plugin:<id> reading failed to activate: <message>, and a plugin record whose status is error with that message on it.
What it means
A declarative plugin is data and cannot fail to activate; a code plugin can. The host imports the entry module named in the manifest and calls its activate function, and every failure in that path is contained: whatever the plugin managed to register before throwing is taken back, the module's tool registrations and event subscriptions are undone, and the record is marked as an error rather than left half-live.
The messages are specific. An entry path that escapes the plugin directory, an entry file that does not exist, and a module that does not export an activate function each produce their own text, and anything thrown inside the plugin's own activation code is passed through verbatim.
What to check
- Read the message after
failed to activate. It is either one of the host's own checks or the plugin's own thrown error. - Check the entry field against the files on disk. It must be a relative path inside the plugin directory and the file must exist.
- Check the module exports an activate function. That is the entire contract for the entry point, and its absence is a common first-run mistake.
- Check the plugin's own dependencies. A missing import throws during the dynamic import, and the message names the module.
- Fix and reload. A failed activation is retried on the next discovery or rescan, and the record's error text clears when it succeeds.
A contribution never appears
What you see. A plugin with a green status, a contribution count of zero for the kind you care about, and no models, skills, routing rules or panels anywhere in the console. The log may hold contributes is not an object; ignored. or a warning naming the specific entry that was dropped.
What it means
Contributions are data, and they are read defensively. The manifest's contribution object has exactly eight keys — providers, models, skills, role templates, pipelines, routing rules, UI panels and tool names — and anything malformed inside them is dropped with a warning rather than failing the plugin. A contribution that survives validation is applied; a contribution that does not is reported in the warnings attached to the plugin and counted nowhere. Contributions are recomputed rather than patched, so a change takes effect on the next load, and a plugin's record carries a count per kind, which is the fastest way to see how much of your manifest actually arrived.
Two structures cause most of these reports. A section placed where the validator does not look, most often settings under contributions, or contributions under settings. And an array of entries where a shape was expected, which is dropped entry by entry.
What to check
- Compare the record's contribution counts with the manifest. A count of zero is a parsing problem; a correct count with no visible effect is an application problem.
- Read the plugin's warnings. They are logged at scope
plugin:<id>when the plugin loads, and each names the entry that was dropped and why. - Check the key is one of the eight.
settingsis a top-level field, and there is no ninth contribution key. - Check the shape of the entries, not just the key: an array is expected for most kinds, and panel widgets are restricted to a small closed set of kinds with their own caps.
- Check the plugin is enabled. Contributions are withdrawn when a plugin is disabled, and the record's status distinguishes disabled from loaded.
The reference for every field, default and cap is Plugin manifest. Where the two disagree, the manifest validator wins, and a page that disagrees with it is a defect in the page.
A plugin tool has a name with the plugin in it
What you see. The tool a plugin registers appears in results and denials under a name the plugin never used, built from the plugin's identifier: an echo tool from a plugin called dev3d.office-echo registers as dev3d_office_echo_echo.
What it means
Every plugin tool name is namespaced so that two plugins cannot collide. The plugin identifier is lowercased, every run of characters outside the lowercase alphanumerics becomes a single underscore, leading and trailing underscores are trimmed, and the result is capped. The tool's own name gets the same treatment and is appended. The combined name is capped as well.
The manifest's declared tool names are a separate fact from the registered ones. The manifest field is a consent record: it tells the operator which tools the plugin is expected to register before it is enabled. What actually exists in the tool registry is whatever the activate function registers, and the two can disagree.
What to check
- Compute the name rather than guessing it. Take the plugin id, prefix it, and compare with the tool list in the console.
- Check the manifest's declared names match reality. A declared name with no registered tool produces an unknown-tool denial when a model calls it.
- Check the tool is granted. A namespaced plugin tool is still subject to per-role grants, and a role that does not hold it gets the ordinary not-granted denial.
- Check the length caps if the name looks truncated. Both the namespace and the combined name are capped, so a very long plugin id can produce a name that looks unrelated.
A disabled plugin is still running
What you see. A plugin's status is disabled and its contributions are gone from the console, yet its module is still in the process and its event handlers or side effects persist.
What it means
Disabling a code plugin withdraws its contributions, unregisters the tools it registered, and calls its deactivate hook. What it cannot do is unload the module: Node has no way to unload an ES module, so the code stays resident in the process until it exits. The host says so rather than pretending otherwise, which is why "disable, then check, then restart if it matters" is the honest procedure.
This is a platform limit rather than an unfinished feature, and it sits alongside the related facts that updates are found rather than pushed and that there is no downgrade path for a bad release.
What to check
- Confirm the contributions were withdrawn. The tool list and the plugin's contribution counts are the evidence, and they update on the plugin state broadcast.
- Restart if you are troubleshooting a plugin misbehaving. A disabled module that still holds a timer, a listener or a socket will not be evicted on its own.
- Remove rather than disable for a marketplace plugin you no longer want, and restart afterwards.
- Check for a handler that throws after disabling. An event handler that fires after deactivation logs a warning at the plugin's own scope rather than crashing the office.
A skill file was skipped
What you see. A warning at scope skills reading skipped my-skill.md — Skill file ... is missing the required frontmatter key "id". The other skills still load and the office boots normally.
What it means
Skill files are hand-written markdown, so the loader is built to lose one file rather than the catalogue. It reads every markdown file in the directory, parses the front matter, and on any failure pushes the file and the reason onto a skip list, logs the reason, and continues. Nothing here is fatal, and this is load-bearing: the loader runs before the server starts listening, so throwing would turn a typo in a markdown file into an office that will not start.
The required keys are the identifier, the name and the description; tags are required too. Optional keys include the task classes, required tools, an estimated token count and a version. When no token estimate is given, one is derived from the body length.
The fix. Correct the front matter and restart. The file loads on the next boot, and nothing else in the catalogue was affected by it having been wrong — which is exactly what the skip-and-continue rule is for.
What to check
- Read the reason after the dash. It names the missing or unparseable key, and a front-matter fence that never closes produces its own parse failure.
- Check the file ends in the markdown extension and is a file rather than a directory; anything else is ignored silently.
- Check the front matter is fenced and well formed. A key with no value is the same as a missing one for the required fields.
- Check the count after fixing it. The boot line reports how many skills loaded, and a reload is a restart.
- If nothing was logged at all, suspect the directory rather than the files. A directory that cannot be created or read yields an empty catalogue with no warning.
A skill loads but is never offered to a turn
What you see. A skill is present in the catalogue and enabled for the floor, yet a turn that should have used it ran without it, and the turn's skill selection does not mention it.
What it means
Selection is deliberately narrow, and each step explains itself in the selection record. Candidates are the intersection of the role's skills and the floor's enabled skills, falling back to every skill the floor has enabled when that intersection is empty. The first two candidates are always included and are labelled as assigned to the role by default. The rest are scored: a match on the turn's task class scores highest and adds the number of overlapping keywords, and a keyword overlap alone scores just that count. Tokens are short lowercase words matched as substrings against the skill's name, description and tags. The engine takes three beyond the two defaults, five in total.
So a skill that is enabled but never selected is usually a skill whose task classes do not cover the stage's kind and whose words do not appear in the brief, the turn's purpose or the stage objective — which is exactly the text that is searched.
What to check
- Read the turn's skill selection, including the reason for each pick. The record says which skills were chosen and whether each came from the role default, a task-class match or a keyword overlap.
- Check the floor enables the skill. The role's own list is intersected with the floor's, and a role whose skills are entirely disabled on that floor falls back to the floor's whole set, which can look like a different bug.
- Check the skill's declared task classes against the stage's kind. Twelve stage kinds map onto the task classes, so a skill scoped to a class no stage produces is never a task-class match.
- Check the words actually overlap. The searched text is the brief, the turn's purpose and the stage objective; matching is substring-based on lowercase words longer than one character.
- Check the ordering when several skills tie. Ties break on the identifier, so a skill can lose a slot to an alphabetically earlier neighbour with the same score.
A plugin tool touched something outside the workspace
What you see. A plugin-provided tool read or wrote a path outside the run's project, and no refusal was produced.
What it means
The host wraps a plugin tool so the engine sees an ordinary tool, and what it forwards is the workspace root, the plugin's stored settings, an abort signal and a logger. It does not resolve paths, does not reject escapes, and does not check that a returned path lies inside the workspace. The root is handed to the plugin as information rather than enforced as a boundary.
Since a code plugin already runs in the orchestrator's process with the orchestrator's authority, this is consistent rather than surprising — but it does mean the confinement story covers the built-in tools and not the tool list an installation actually has once a plugin is loaded. The same is true of the shell tool, which is confined by its working directory and nothing else.
This is a known gap rather than a configuration mistake. The host does not enforce the workspace boundary for plugin tools, and it documents that rather than implying otherwise. See Known gaps.
What to check
- Read the confinement section of Known gaps rather than relying on a summary that covers only the built-ins.
- Treat the plugin's permission list as a disclosure that happens to be enforced twice. It tells you what the plugin says it does; registering a tool and subscribing to the event stream are genuinely refused without the matching permission, and the other eight are statements rather than gates.
- Read the plugin's own source if it registers tools, and apply the workspace guard inside it if you need one, using the same rules the built-ins use.
- Run the office under an account whose access matches what you are willing to grant, because at this boundary the operating system is the only enforcement point there is.
A plugin panel shows an error instead of data
What you see. A panel rendering an error string: the panel endpoint returned HTTP 404., could not reach the panel endpoint: ..., the panel endpoint returned nothing renderable., or this installation already reads 12 live panels; this one was not fetched.
What it means
A panel is either static widgets declared in the manifest or a live source the console polls. A live panel's first read happens when a console opens it, and then on the interval the manifest asks for, with a floor of five seconds and a default of thirty. Nothing is fetched while no console is open. The read is bounded: it has its own timeout, its result must be renderable as one of the six widget kinds, and the server refuses to track more live panels at once than its limit, saying so in the response rather than pretending the panel is empty.
A source URL must be an http or https address, and the refresh interval is clamped into a range, so a panel that refreshes far slower or faster than asked is being clamped rather than ignored.
What to check
- Check the source endpoint directly. An HTTP status in the panel message is the endpoint's own answer, and the same status will appear from curl.
- Check the endpoint returns the shape a panel expects. A response that parses as JSON but contains none of the recognised widget kinds is reported as nothing renderable.
- Count the live panels in the installation. The limit is per installation rather than per plugin, and the message names it.
- Check the refresh interval you declared. It is clamped, so a panel that appears to ignore its settings is being brought into range.
- Check the panel is not the only thing broken. Panels are read on demand and carry their own errors; a plugin whose tools work and whose panel does not is a panel problem, not a plugin problem.
When it is not in this list
Both extension points record enough to make a specific report cheap. Include:
- For a plugin: its identifier, version, source and status, the error text on its record, its contribution counts per kind, and any warnings logged at scope
plugin:<id>when it loaded. - For an install: the exact refusal message, which distinguishes a gate from a digest problem from an archive-shape problem in one line.
- For a skill: the skip line with its reason, or the turn's skill selection record if the file loaded and was not chosen.
- The manifest itself, or at least the failing section of it, since the validator's report is only meaningful beside it.
- Whether the problem survives a restart, which separates a stale module from a configuration mistake.
Send it through contact. The reference pages for both formats are Plugin manifest and Writing a skill, and the honest list of what neither format can enforce is in Known gaps.
Where to go next
Linked from
Did this page answer your question?