Skip to content

Plugin manifest

The full field reference for plugin.json.

14 min readUpdated 13 Sept 2026Reviewed 12 Sept 2026Published 12 Sept 2026/docs/reference/plugin-manifest

A plugin is a directory containing a plugin.json and, optionally, a code entry module. This page is the field-by-field contract: what each field is, whether it is required, and what the validator does with it.

The manifest is treated as untrusted input, because it is: it arrives from disk or from a marketplace and the host is about to act on it. Validation therefore splits failures into two kinds, and the distinction is the most useful thing on this page.

  • Problems reject the whole plugin. If the host cannot tell what it would be running, it does not run it. A bad id, a missing version, an apiVersion it cannot honour, a malformed settings schema.
  • Warnings drop one malformed contribution and keep the rest. A typo in one model entry costs you that entry, not the plugin. Every warning is reported, which is what makes the trade acceptable: a plugin that silently loses half its models is worse than one that refuses to load, but only if the loss is invisible.

Top-level fields

FieldRequiredNotes
idyesLowercase reverse-dns style with at least two dot-separated segments, matching ^[a-z0-9][a-z0-9-]*(.[a-z0-9][a-z0-9-]*)+$, at most 64 characters. This is the plugin's identity everywhere: the enable/disable key, the settings key, the tool-namespace seed. dev3d.cost-guard is the shape.
nameyesThe display name shown in the console. Any non-empty string.
versionyesShape-checked only, against ^d+.d+.d+(?:-[0-9A-Za-z.-]+)?$. Validation does not compare versions — the comparison happens later, when a marketplace offers an upgrade, in a helper the source explicitly documents as not a semver implementation. It reads the first three dot-separated integers plus an optional pre-release tag and treats a missing part as zero, so compareVersions('1.2', '1.2.0') === 0. "Is this newer" is the only question ever asked, which is why the shape is strict but the comparison is deliberately loose.
apiVersionyesThe plugin API this was built against. The host implements 1, and only the leading integer is compared: 1 and 1.4 both work, 2 is refused. See below.
descriptionyesShown on the card before anything is installed. Required, despite the type comment in the source calling it optional — the validator rejects a manifest without one.
authornoAttribution, shown in the console.
homepagenoAttribution, for the marketplace listing and the plugin card.
licensenoAttribution only. Nothing enforces it, and nothing checks it against the code that ships alongside it.
entrynoA path to a module exporting activate(api). Its presence is what makes a plugin a code plugin: it must be relative, may not be absolute or drive-qualified, and may not contain a .. segment that escapes the plugin directory.
permissionsnoAn array of permission names from the fixed set of ten. Enforced for the two capabilities the host mediates, and a consent record for everything else — see below.
settingsnoAn array of field descriptors rendered as a form. Unlike contributions, a malformed settings schema rejects the whole plugin, because the console has nothing sensible to draw.
contributesnoThe eight seams the plugin reaches into. Absent or empty means a plugin that loads and does nothing, which the validator allows.

apiVersion and compatibility

Compatibility is a major-version comparison, and nothing else. A plugin declaring 1 or 1.9 is compatible with a host at 1; a plugin declaring 2 is refused with targets API "2", but this host implements "1". A version string with no leading integer is unparseable and therefore incompatible, and a missing apiVersion produces is required; this host implements "1".

This is deliberately strict rather than tolerant. There is no "best effort" path, because a plugin written against expectations the host cannot promise is not a plugin that mostly works — it is a plugin whose author believed something untrue about the environment it would run in.

What the operator sees when a plugin is not loaded

A plugin record has a status of loaded, disabled or error, and the three are genuinely different states rather than degrees of the same one. disabled means the operator turned it off or it defaulted to off; its contributions are withdrawn and it is not running. error means it was found and could not be loaded, and the record carries the reason string — which for a manifest failure is the validator's own field: message list joined with ; . An errored plugin still appears in the console, and re-enabling it retries the load, which is what makes a broken manifest fixable rather than invisible.

The eight contributes keys

Eight, and only eight. The number is worth stating because it is easy to assume there is a key for every concept in the system; there is not. In particular settings is a top-level manifest field rather than a contribution, and tools are contributed by declaring their names and registering them from code.

KeyWhat it reachesShape
providersA whole provider: something the router can send a turn to. Merged into the provider registry, so its models become reachable and the Providers panel lists it with the plugin as its source. A contributed provider's id may not collide with a built-in one — the built-in wins.Array of objects with id, label, kind, baseUrl and either keyEnvVar or keyless. kind is openai-compat or anthropic.
modelsThe model catalog the router chooses from, and the Models tab. A model declared here is available to every floor immediately, subject to the router's normal rules — it is a catalogue entry, not a preference.Array of model specs. id, providerId and a valid tier are required; label, contextWindow, maxOutputTokens, costPerMTokIn and costPerMTokOut have defaults.
skillsThe skill library. A contributed skill behaves exactly like a skills/*.md file: it appears in the index, a floor can enable it, and per-turn selection can pull its body in.Array of objects with id, name, description, tags and an inline markdown body. Optional taskClasses.
roleTemplatesThe hire form. Offered alongside the shipped company's roles, with the plugin named as the source. Never applied to a floor automatically: a plugin does not get to change who works here.Array of role objects. id, displayName and title are required; the rest passes through.
pipelinesOffered to every floor, because a plugin cannot know which organisations exist. A floor's employees still have to be able to staff it, so a pipeline with roles nobody holds is offered and then refused at submission with a reason.Array of pipeline objects with id, name and a stages array.
routingRulesThe router's candidate ordering. Rules are preferences consulted while the router builds its walk, and they cannot add a candidate that failed capability filtering. A rule naming a tier does move the walk, and that tier is not clamped to the policy band.Array of rules with id and description, plus optional taskClass, tier, preferProviderIds, preferModelIds and avoidModelIds.
uiPanelsThe console, in one of four placements. Declarative only: a panel is a closed set of widget shapes, and a live panel's URL is fetched by the server so it never reaches the browser.Array of panel objects with id, title, placement, summary, and a body and/or source.
toolNamesTool names the entry module will register, declared so the consent screen can name them before anything runs. It reaches the registry only through code: a manifest with toolNames and no entry declares names that can never appear. Registered names are namespaced — the plugin id is lowercased, every run of non-alphanumerics becomes an underscore, and the result is capped at 40 characters — so dev3d.office-echo registering echo becomes dev3d_office_echo_echo, and two plugins cannot collide on a name.Array of strings.

Contributions are recomputed, never patched. Enabling a plugin, disabling it, changing its settings or rescanning the directory causes the active contribution set to be rebuilt from the loaded manifests. There is no incremental merge to get out of step, and there is also no way to contribute something conditionally at runtime from the manifest — conditional behaviour belongs in code.

The ten permissions

A permission is enforced where the host mediates a capability, and a consent record everywhere else. That distinction is the whole point of this section, so it is worth being exact. Two checks exist: registering a tool requires tools, and subscribing to the event stream requires events, each refused with <id>: <what> needs the "<permission>" permission, which its manifest does not declare. The other eight are declarations rather than gates. And activate() itself is ordinary code running with the orchestrator's authority, so a plugin that wants to do something the host does not mediate does not need a permission to do it. What the list buys is therefore two real refusals and a great deal of honesty: it tells the person deciding whether to enable a plugin what kind of thing it is — a plugin that registers tools is asking for something categorically different from one that adds two models — and a plugin that ships an entry with no permissions at all is flagged with the warning declares an entry module but no permissions.

PermissionWhat declaring it says the plugin does
modelsAdds or retunes catalog entries: new model specs, corrected prices or tiers.
providersRegisters a whole provider adapter — a new place turns can be sent.
toolsRegisters tools that execute inside the orchestrator's process.
routingInfluences which model a turn gets, through routing rules.
skillsAdds skill documents to the library.
agentsContributes role or agent templates offered in the hire form.
pipelinesContributes pipelines a floor can run.
settingsAdds operator-facing configuration — a settings form.
uiContributes panels and visual tokens to the console.
eventsObserves the server event stream.

An unknown permission name is not dropped — it is a problem that rejects the whole manifest, with the message unknown permission(s): foo, bar. That is the opposite of how a bad contribution behaves, and the reason is that permissions are a fixed vocabulary the console has to render; an unrecognised one cannot be displayed honestly.

Two warnings hang off this field. declares an entry module but no permissions. fires when code ships without any declaration. asks for the "tools" permission but ships no code, so it cannot register one. fires the other way round. Neither is fatal, and both exist because the field is the operator's only summary of intent.

Settings field types

settings is an array of field descriptors, rendered as a form in the plugin's console page. Four types exist.

Typedefault must beExtra fields
stringa stringdescription
numbera numbermin, max — enforced on every write, not just in the form
booleana booleandescription
selecta string that appears in optionsoptions is required; a select without it is rejected

Every field needs a key matching ^[A-Za-z][A-Za-z0-9_]*$, a label, and a default whose type matches the declared type. Keys must be unique within the manifest. A field that fails any of these is a problem: must be a simple identifier., duplicate setting key "x"., is required., must be string, number, boolean or select., must match type "select"., a select needs options., must be one of the options.

Stored values are coerced against the schema on every read, which is what makes a plugin able to rename or retype a setting without being handed a stale value it would misinterpret. A value that is not declared any more, or whose type no longer matches, or which falls outside min/max, is dropped and the default is used. The plugin never sees the value it cannot handle.

Panels: the widget set and its caps

A panel's body is a closed set of six widget kinds. A plugin describes what it wants shown; the host decides how it looks. That is the whole browser-facing trust model: a marketplace plugin cannot reach the page, the socket or the session, because all it ever contributes is data.

KindShapeNotes
metriclabel, value, optional unit, optional hintDropped if it has no value. An empty label becomes value.
keyValuelabel, rows: [{ key, value }]Dropped if no row survives. An empty string value is legitimate — "nothing configured yet" is a real answer — and renders as an em dash.
tablelabel, columns, rows: string[][]Dropped if no column survives. Every row is trimmed and padded to the column count with em dashes, so a short row shows an honest gap rather than shifting the columns.
listlabel, itemsEmpty items are filtered out rather than rendered as blanks.
barslabel, bars: [{ label, value, max }]Dropped if no bar survives. A max at or below zero is ignored.
notetextDropped if the text is empty after control characters are stripped.

Every cap is a truncation rather than a rejection, because a panel is decoration and losing a row must not cost the plugin. 24 widgets per body, 60 rows in a key-value widget or table, 60 items in a list or bars widget, 8 columns in a table, 500 characters of text (with the truncation marked by an ellipsis) and 120-character labels. Control characters are stripped from every string before it is measured.

A panel's source is a live alternative or addition: an http or https URL the server fetches and validates, expecting { widgets: PanelWidget[] }. Anything not matching ^https?:// is ignored with uiPanels[0].source.url must be an http(s) URL; ignored. refreshMs is clamped to [5000, 3600000] — a floor on the refresh interval, so a plugin cannot turn the console into a request amplifier. Because the browser never sees the URL, a plugin endpoint cannot be used to reach the operator's machine, and a slow or dead one costs a panel rather than the console. A panel declaring neither a body nor a source gets the warning declares neither a body nor a source, so it has nothing to show.

Three more limits govern how the server reads those live sources, and all three are defence against a plugin being merely broken rather than hostile. A source with no refreshMs is re-fetched no more often than every 30 seconds. Each fetch has a 10-second timeout, after which the panel reports could not reach the panel endpoint: … instead of hanging. And at most 12 distinct live panels are read per installation: past that, a panel answers this installation already reads 12 live panels; this one was not fetched. Responses are cached for at least the panel's own refresh interval and concurrent requests for the same panel are coalesced onto a single fetch, so an open console cannot be turned into a request amplifier by a panel that many tabs are watching. A manifest-declared body is free and cannot fail, so it is never fetched and never cached.

The four placements are inspector, runs, office-overlay and settings. Anything else produces uiPanels[0] needs id, title and a known placement; dropped. A panel may also carry tokens, a map of CSS custom properties it needs; non-string values are ignored.

Validation: what rejects, what drops

Rejects the whole plugin

  • The manifest is not a JSON object: manifest: must be a JSON object.
  • id missing (is required.), longer than 64 characters, or not reverse-dns (must be lowercase reverse-dns style with at least two segments, e.g. "dev3d.cost-guard".).
  • name, version or description missing; a version that is not semver (must be semver, e.g. "1.0.0".).
  • apiVersion missing or major-incompatible.
  • permissions not an array, or containing an unknown name.
  • entry empty (must be a non-empty relative path.), absolute or drive-qualified (must be relative to the plugin directory.), or containing a .. segment (may not escape the plugin directory.).
  • Any single malformed settings field, since the schema is a form and a broken form is not renderable.

When several problems are found, all of them are collected before the manifest is refused and the report joins them with ; as field: message pairs. A plugin that fails to load for this reason appears in the console with status error and that string as its reason, so it is fixable rather than invisible.

Drops one entry and continues

Contribution arrays are permissive by design, and every drop is reported as a warning.

  • Wrong container. contributes.models is not an array; ignored. — the same shape for skills, routingRules, uiPanels, roleTemplates, pipelines and providers.
  • A non-object entry. models[0] is not an object; dropped.
  • A model missing essentials. models[0] needs id, providerId and a valid tier; dropped. A strengths value that is not a known task class is silently filtered rather than warned about.
  • A skill without a body. skills[0] needs id, name, description and a non-empty body; dropped.
  • A routing rule without an id. routingRules[0] needs an id; dropped. A rule with an unknown taskClass or tier is kept but loses that field, which is worth knowing: a typo in a tier silently widens the rule instead of failing.
  • A provider problem, the richest set: an id that is not a flat lowercase slug (the pattern is ^[a-z][a-z0-9-]{1,31}$ — 2 to 32 characters — with the message providers[0].id "MyGateway" must be a lowercase slug; dropped.), a duplicate id (is declared twice; the second is dropped.), a plaintext base URL that is not loopback-with-keyless (providers[0].baseUrl must be https (or http on the loopback address with "keyless": true); dropped. — the loopback exception matches ^http://(127.0.0.1|localhost|[::1])(:d+)?(/|$), so http://127.0.0.1:11434/v1 passes and http://192.168.1.9:8000/v1 is refused), a kind that is neither openai-compat nor anthropic, a keyEnvVar that is not an environment-variable name (the pattern is ^[A-Z][A-Z0-9_]{2,63}$, so a lowercase or two-character name fails with is not an environment variable name; dropped.), a header value containing a carriage return or newline (extraHeaders["x"] is not a safe header; ignored.), or a provider with neither a key variable nor keyless — names neither "keyEnvVar" nor "keyless": it would never count as configured; dropped.
  • A panel problem, per widget or per panel, as described above.
  • Every settings field rejected. every settings field was rejected.

What a plugin can and cannot do

  • It cannot ship a credential. A manifest names an environment variable; the server reads it. A marketplace bundle therefore never carries a secret, and a provider without a key is catalogued but not routable.
  • It cannot reach the browser. Panels are data. A live panel's URL is fetched server-side.
  • It cannot escape its own directory with entry, and the archive reader refuses absolute paths, .., links, device nodes, more than 2048 files and more than 32 MB expanded.
  • Its tools are not confined by the host. The host forwards the run's workspace root and nothing else, so a tool a plugin registers is trusted code in the orchestrator's process. Reading the permissions list is the closest thing to a gate there is, which is exactly why it should be read.
  • It stays resident after being disabled. Disabling withdraws the contributions and calls deactivate(), but Node cannot unload an ES module, so the code stays in memory until the process restarts.

Cross-checking a manifest you did not write

  1. Read permissions first, because it is the only statement of intent.
  2. Check whether entry is present. If it is, the plugin runs code in the server process.
  3. Check contributes against the counts the console shows on the plugin card — a provider that declared six models and contributed two has already failed validation somewhere, and the warnings say where.
  4. Check apiVersion. A mismatch is the one failure that is completely unambiguous and completely unactionable except by updating the plugin.
  5. If a panel is empty, check whether it has a body or a source, and whether the source URL answers with a widgets array.

Linked from

Did this page answer your question?