Building a plugin
Declarative or code, what a manifest may contribute, and the rules the host enforces.
A plugin is how the office gains a provider, a model, a skill, a routing rule, a tool, a role template, a pipeline or a console panel without anyone editing the application. There are two kinds, and the distinction is exactly one field.
Declarative or code: it is the entry field
The rule has no exceptions and no heuristics. A code plugin is a plugin that ships an entry module. The host reads entry, resolves it inside the plugin directory, imports it, and requires it to export activate(api). No entry, no import, no code — the plugin is data the validator has already capped, and the console marks it hasCode: true when it does ship code, so an operator can see what they are enabling.
What a code plugin can reach. activate() runs in the orchestrator's process with the orchestrator's authority. A tool it registers is handed the run's workspaceRoot — the host does not police it, unlike the built-in filesystem tools, which route through their own path check. It reads the same environment, including your provider keys. There is no sandbox, and the source says so rather than implying otherwise.
The contribution surfaces: eight keys
Everything a manifest may contribute lives under one top-level contributes object, and there are exactly eight keys. This is worth stating flatly because the number eleven has been published on this site and in the project's own material, and it is wrong.
| Key | Type | What it reaches |
|---|---|---|
providers | PluginProvider[] | A whole provider: an adapter kind, a base URL, and the name of the environment variable holding the key. The registry builds adapters from what the host reports, so a plugin provider really does become a routable source. |
models | ModelSpec[] | Catalog entries merged into the provider registry, so the router can choose them and the Models view lists them. |
skills | PluginSkill[] | Markdown skills carried inline — id, name, description, tags, optional task classes and the body as a string. See Writing a skill. |
roleTemplates | Role[] | Roles offered in the hire form. Never applied to an organisation automatically — a plugin does not get to change who works here. |
pipelines | Pipeline[] | Pipelines offered to every floor, since a plugin cannot know which organisations exist. |
routingRules | RoutingRule[] | Preferences consulted when the router orders candidates, optionally scoped by taskClass. |
uiPanels | UiPanelContribution[] | Declarative console panels. A closed set of six widget kinds — no plugin code runs in the browser. |
toolNames | string[] | The names of tools a code plugin registers, declared up front so the consent screen can list them before anything is enabled. |
The counts that get confused with each other
"Eleven contribution surfaces" does not correspond to any list in the source. Three real numbers sit close enough to be mistaken for it, and they mean different things:
| Number | What it is | Where it lives |
|---|---|---|
| 8 | The keys of contributes — the actual surfaces a manifest can contribute to. Also the eight fields of the contribution counts the console shows. | PluginContributions, PluginContributionCounts |
| 10 | The permissions a plugin can ask for. A separate vocabulary: it says what a plugin may touch, not what it contributes. | PluginPermission |
| 10 | The fields of the host's live ActiveContributions — the eight above plus tools, which only exists once code has run, and toolOwners, which maps a tool name back to the plugin that registered it. | ActiveContributions |
| 9 | A table row count in the project's own README, which lists contributions slightly differently. | Not authoritative |
settings is not a contribution
One more field is easy to misfile. settings is a top-level manifest field, a sibling of contributes, not a key inside it. It declares the operator-facing form: an array of fields with a key, a label, a type of string, number, boolean or select, and a default whose JavaScript type has to match. Put it under contributes and the validator ignores it, the console shows no form, and api.settings arrives empty in your code — a genuinely confusing afternoon. The full field list is on the plugin manifest reference.
The ten permissions
Permissions are a separate set of ten strings, and they are enforced for exactly two of them: registering a tool requires tools, and subscribing to the event stream requires events, each refused with a message naming the missing permission. The other eight are declarations. The distinction still matters to a publisher, because the list is what an operator reads before enabling anything: it appears in the console so they can see that this plugin is asking for something categorically different from one that adds two models. It is not a sandbox — activate() runs with the orchestrator's authority, so a plugin can do plenty the host never mediates — and the honest way to describe it is "two real refusals and a disclosure". The ten are models, providers, tools, routing, skills, agents, pipelines, settings, ui and events. An unknown value is not ignored: listing one refuses the whole plugin and names it. Two validator warnings keep the list honest, too — a manifest with an entry and no permissions gets "declares an entry module but no permissions.", and one that asks for tools with no entry gets "asks for the "tools" permission but ships no code, so it cannot register one." Neither refuses the plugin; both are logged against it.
A complete annotated manifest
This is a real shape — it is a compressed version of the shipped dev3d.cost-guard, which is a pure declarative plugin. Every field here is one the validator reads.
{
"id": "dev3d.cost-guard",
"name": "Cost guard",
"version": "1.1.0",
"description": "Keeps the cheap work cheap: routes intake and summarising to the least expensive capable model, and offers a cost-auditor role.",
"apiVersion": "1",
"author": "dev3d",
"license": "MIT",
"permissions": ["models", "routing", "skills", "agents", "settings"],
"settings": [
{
"key": "aggressiveness",
"label": "How hard to push down-tier",
"type": "select",
"description": "Balanced honours the role's policy and only overrides the cheapest task classes.",
"default": "balanced",
"options": ["gentle", "balanced", "aggressive"]
},
{
"key": "maxPromptTokens",
"label": "Warn above this prompt size",
"type": "number",
"default": 12000,
"min": 1000,
"max": 200000
}
],
"contributes": {
"models": [
{
"id": "local/qwen2.5-coder-7b-instruct",
"providerId": "local",
"label": "Qwen 2.5 Coder 7B (local)",
"tier": "small",
"contextWindow": 32768,
"maxOutputTokens": 4096,
"costPerMTokIn": 0,
"costPerMTokOut": 0,
"capabilities": { "tools": true, "vision": false, "reasoning": false, "streaming": true },
"strengths": ["summarize", "intake", "routing"]
}
],
"skills": [
{
"id": "cost-aware-delegation",
"name": "Cost-aware delegation",
"description": "Decide whether a task needs a frontier model at all, and say which tier is justified.",
"tags": ["cost", "process", "routing"],
"taskClasses": ["routing", "summarize", "planning"],
"body": "# Cost-aware delegation\n\nBefore you reach for a strong model, answer three questions in writing..."
}
],
"routingRules": [
{
"id": "cheap-first-for-mechanical-work",
"description": "Intake is mechanical: prefer the cheapest capable model.",
"taskClass": "intake",
"tier": "small",
"preferProviderIds": ["local", "deepseek"]
},
{
"id": "no-frontier-for-summaries",
"description": "A summary never needs the strongest tier.",
"taskClass": "summarize",
"tier": "nano",
"avoidModelIds": ["gpt-4o", "claude-3-5-sonnet-latest", "o3-mini"]
}
],
"roleTemplates": [
{
"id": "cost-auditor",
"displayName": "Mira",
"title": "Cost auditor",
"departmentId": "frontend",
"rank": 2,
"mission": "keep the office honest about what it spends",
"skillIds": ["cost-aware-delegation", "technical-writing"],
"allowedTools": ["think", "read_file", "list_dir", "search_files"],
"modelPolicy": { "defaultTier": "standard", "minTier": "small", "maxTier": "strong" },
"maxTurnsPerStage": 4
}
],
"pipelines": [
{
"id": "spend-review",
"name": "Spend review",
"description": "Read the runs the office has already paid for and say which spend bought nothing.",
"stages": [
{ "kind": "review", "name": "Audit", "roleIds": ["cost-auditor"], "mode": "single" },
{ "kind": "report", "name": "Report", "roleIds": ["cost-auditor"], "mode": "single" }
]
}
]
}
}
Notice what is not here: no entry, so this is declarative and nothing in it can execute. Also notice that roleTemplates and pipelines are passed through on the strength of only a few required fields — id, displayName and title for a role; id, name and a stages array for a pipeline. The validator's job there is to be sound enough to offer to an operator, not to guarantee the engine will accept every field.
The validation rules, and which ones refuse the plugin
Validation is deliberately split in two, and the split is the single most useful thing to understand about plugin development.
- Problems reject the whole plugin. A bad
id, a missing version, anapiVersionthe host cannot honour — the host does not know what it would be running, so it does not run it. - Warnings drop one thing and keep the rest. A typo in one model entry costs you that entry, is reported with its index, and is logged as a warning against the plugin.
| Field | Rule | Result |
|---|---|---|
id | Required; 64 characters or fewer; lowercase reverse-dns with at least two dot-separated segments, each starting with a letter or digit | Problem — the plugin is refused |
name | Required, non-empty after trimming | Problem |
version | Required; 1.2.3 with an optional pre-release tag such as -beta.1 | Problem |
description | Required, non-empty | Problem |
apiVersion | Required, and its major must equal the host's. 1 and 1.2 both match this build; 2 is refused with the exact mismatch in the message | Problem |
permissions | Must be an array of known permission strings; one unknown value refuses the plugin and names it | Problem |
entry | Relative only. Absolute, drive-letter and ..-containing paths are refused | Problem |
settings | Must be an array; each key a simple identifier, unique; label required; default's type must match type; a select needs options and its default must be one of them | Problem |
contributes.providers[] | id a lowercase slug; label and baseUrl required; base URL https, or http on loopback when keyless is true; kind one of two values; a keyEnvVar matching an environment-variable shape, or keyless | Warning — that provider is dropped |
contributes.models[] | id, providerId and a valid tier required. Missing contextWindow, token caps and costs default; an unrecognised strengths value is filtered out | Warning |
contributes.skills[] | id, name, description and a non-empty body | Warning |
contributes.routingRules[] | id required; an unknown taskClass or tier is simply not applied; empty list properties are dropped | Warning |
contributes.uiPanels[], roleTemplates[], pipelines[] | An id, a title or display name, and a known placement for a panel, displayName and title for a role, name and a stages array for a pipeline | Warning |
The rules the host enforces, with the real behaviour
A bad plugin is contained
A directory whose plugin.json is missing, is not valid JSON, or fails validation becomes a row with status: 'error' and the error text, keyed on the directory. A module that throws on import becomes such a row. A module that fails halfway through activate() has whatever it managed to register taken back — its tools unregistered, its event subscriptions dropped — and then becomes such a row. In every case the office boots: the plugin host is loaded after the office is listening, precisely so that a plugin cannot be the reason the process fails to start. This containment is the whole reason the reject-versus-drop split exists: a missing id refuses the plugin outright, because without one the host cannot key a record and can only report the failure at directory level, while a typo in one model entry costs that entry and is reported with its index. A plugin that silently loses half its models would be worse than one that refuses to load — but only if the loss is reported, and warnings are reported both to the log and beside the plugin in the console. Enabling a plugin that is in error re-reads and re-activates it from disk, which is the operator's retry mechanism; that is also why re-enabling an already-broken plugin is not reported as a no-op success, since a retry button that lies is worse than no retry button.
Contributions are recomputed, never patched
Every derived list — models, skills, tools, routing hints, panels, role templates, pipelines — is rebuilt by walking the currently loaded and enabled plugins. Nothing is incrementally merged. That is why disabling a plugin withdraws its models and routing hints immediately, and why replacing a manifest takes effect on the next recompute: there is no cache of contributions to invalidate, because there is no cache.
Tool names are namespaced
Registration goes through a function, and the real output is worth stating exactly. The plugin id is lowercased, every run of characters outside [a-z0-9] becomes an underscore, leading and trailing underscores are trimmed, and the result is capped at 40 characters. The tool name gets the same treatment, the two are joined with an underscore, and the whole thing is capped at 64 characters. So dev3d.office-echo registering echo becomes dev3d_office_echo_echo, and dev3d.cost-guard registering spend-report becomes dev3d_cost_guard_spend_report.
Two consequences follow. A role's allowedTools has to name the namespaced form, because that is the name in the registry, and a granted name that does not resolve is silently filtered out. And two plugins cannot collide by accident, because the namespace derives from a unique id; registering a name that already exists throws "tool "<name>" is already registered." and leaves the plugin in error rather than half-registered.
Providers name an environment variable, never a key
A provider contribution has a baseUrl, a kind of either openai-compat or anthropic, and either a keyEnvVar — the name of an environment variable such as MYLLM_API_KEY, not its value — or keyless: true for a runtime that needs no credential. The server reads that variable itself, from its own process environment. The shape is deliberate: it keeps a secret out of a manifest that will be published, so a marketplace bundle never carries a credential it could leak. A provider that declares neither is dropped with the reason "it would never count as configured", which is true — a provider with no way to authenticate is a row in a list and nothing more. The URL rule is the same kind of decision: baseUrl must be https, with one exception, plain http on the loopback address when keyless is true, so that a plugin cannot quietly point the office at a plaintext endpoint somewhere on the network and start sending it prompts.
A routing rule is a preference, with two caveats
Rules cannot add candidates. They reorder the ones that already survived capability filtering, by adding score bonuses: +0.08 for a preferred model id, +0.04 for a preferred provider, and −0.12 for an avoided model. So a rule can never make the router pick a model that lacks tool calling or a big enough context window. Two things people get wrong, both of which the source is explicit about:
- A rule that declares a
tierreally does move the walk. Pulling that tier to the front of the candidate order is the only mechanism by which a hint can change the tier the router settles on — and that tier is not clamped to the role's policy band. This contradicts a claim in the project's own README, and the code's comment is the authority. avoidModelIdsis a penalty, not an exclusion. A sufficiently cheaper or better-fitting model can still win through a −0.12 penalty. And becausetaskClassis optional, a rule that omits it applies to every task class, not to none.
Panels are data, and a closed set of shapes
A panel is never code. The manifest describes what it wants shown, the host writes it into a closed set of widgets, and the console renders them. Six widget kinds exist: metric, keyValue, table, list, bars and note. Anything else is dropped and counted.
| Cap | Value | Why |
|---|---|---|
| Widgets per panel body | 24 | A body is untrusted data destined for the DOM |
Rows in a keyValue, table or bars | 60 | A panel must not wedge the console with a million-row table |
Items in a list or bars | 60 | Same |
Columns in a table | 8 | Anything wider is unreadable anyway |
| Any text value, or any label | 500 and 120 characters | Over-long strings are truncated with an ellipsis, not rejected |
source.refreshMs | clamped to 5000–3600000 | A panel is decoration; a plugin must not turn the console into a request amplifier |
A live panel does not fetch from the browser. It declares source.url, which must match ^https?://, and the server fetches it and validates whatever comes back as widgets. That is why the URL is safe to accept from a manifest: the operator's machine never contacts it, and a slow or dead endpoint costs one panel rather than the console. Three more numbers govern that fetch — a 30-second default refresh when a panel does not declare refreshMs, a 10-second timeout, and a ceiling of 12 live panels per installation, with the thirteenth reported rather than fetched.
The four placements are inspector, runs, office-overlay and settings. An unknown placement drops the panel with a warning, and a panel that declares neither a body nor a source gets a warning saying it has nothing to show — not an error, but not useful either.
Unloading is best effort, and the code says so
Disabling a code plugin withdraws its contributions, unregisters its tools, drops its event subscriptions and calls deactivate() if the module exports one. What it cannot do is unload the ES module: Node has no mechanism for that, so the code stays resident in the process until the next restart. If your deactivate() needs to release something the module itself allocated, that is your job and it runs inside a try/catch — a throwing deactivate() is logged as a warning and otherwise ignored. Treating "disabled" as "unloaded" would be wrong, and the host does not pretend otherwise.
A code plugin, walked through
The shipped dev3d.office-echo exists to prove the code path and to be copied. Here is its manifest, in full:
{
"id": "dev3d.office-echo",
"name": "Office echo",
"version": "1.0.0",
"description": "A minimal code plugin: registers one tool employees can call, and logs run lifecycles. It exists to prove the code path and to be copied.",
"apiVersion": "1",
"author": "dev3d",
"license": "MIT",
"entry": "index.mjs",
"permissions": ["tools", "events", "settings"],
"settings": [
{
"key": "prefix",
"label": "Echo prefix",
"type": "string",
"description": "Prepended to whatever the tool echoes back.",
"default": "echo"
},
{
"key": "announceRuns",
"label": "Log every run that starts",
"type": "boolean",
"description": "Writes a debug line to the orchestrator log when a run is created.",
"default": true
}
],
"contributes": {
"toolNames": ["echo"]
}
}
And here is index.mjs:
/**
* A minimal dev3d code plugin.
*
* The module must export activate(api), and may export deactivate().
* It must not import anything outside its own directory unless it ships it -
* the host does not install a plugin's dependencies.
*/
/** @param {import('@dev3d/core').PluginApi} api */
export function activate(api) {
const prefix = typeof api.settings.prefix === 'string' ? api.settings.prefix : 'echo';
api.registerTool({
name: 'echo',
description:
'Repeat a short string back to you. Use it to check what you actually sent before ' +
'committing to a longer plan.',
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: 'The text to echo back.' },
},
required: ['text'],
additionalProperties: false,
},
async run(args, ctx) {
const text = typeof args.text === 'string' ? args.text : '';
if (text === '') {
return { ok: false, content: 'echo needs a non-empty "text" argument.' };
}
if (ctx.settings.announceRuns === true) {
ctx.log('debug', 'echo was called with ' + text.length + ' characters in ' + ctx.workspaceRoot);
}
return {
ok: true,
content: prefix + ': ' + text,
preview: prefix + ': ' + text.slice(0, 60),
affectsPaths: [],
};
},
});
if (api.settings.announceRuns === true) {
api.on('run.created', (event) => {
api.log('info', 'a run started: ' + event.run.id + ' on "' + event.run.pipelineId + '"');
});
}
}
export function deactivate() {
// Nothing to release: the host unregisters this plugin's tools and drops its
// event subscriptions for us.
}
Read it as a checklist of everything a code plugin can do and nothing more.
- The module exports
activate(api). That is the entire entry contract. A module without it fails with "entry module "index.mjs" does not export activate()." and the plugin lands inerror.deactivate()is optional and is picked up if present. api.settingsarrives with the manifest defaults already merged in, and re-merged against the stored values on every change — soapi.settings.prefixis"echo"until an operator changes it, and a setting the manifest no longer declares is never handed to you at all.- A tool is an object with
name,description,parametersand an asyncrun(args, ctx). After namespacing, this one becomesdev3d_office_echo_echo, and that is the name a role must list inallowedToolsto be granted it. - Return a result object, do not throw. A failure is
{ ok: false, content: '...' }with a sentence the model can act on. The host translates anok: falseresult into a tool result the model sees, so the message incontentis the error text. ctx.workspaceRootis handed to you and is not policed. Unlike the built-in filesystem tools, a plugin tool gets the root as a value and is trusted to respect it. If your tool writes a path, resolve it against the root yourself.api.on(type, handler)subscribes to the office event stream and returns an unsubscribe function. There are 27 event types to choose from, fromrun.createdandturn.finishedtorouting.decisionandtool.result; the full list is on the wire protocol. A handler that throws is logged as a warning and does not kill the run — one broken observer is not an outage.api.log(level, message)writes to the orchestrator log scoped toplugin:<id>, which is how you find your own output.
Two practical constraints the file states in its own header. The module must not import anything outside its own directory unless it ships that file, because the host does not install a plugin's dependencies; there is no npm step in the plugin lifecycle. And it should be written as ESM — the host imports it by URL, so .mjs or a "type": "module" package is what actually works.
Bundles: what a plugin archive may contain
Packaging a plugin for installation is a .tar.gz of the plugin directory, and the host reads it as attacker-controlled input. It is a hand-written POSIX ustar reader over Node's zlib, not a zip library, and it refuses rather than sanitises:
| Refused | Real message |
|---|---|
| An absolute path | archive entry "/etc/passwd" is an absolute path. |
A .. segment, or any path resolving outside the destination | archive entry "../x" escapes the plugin directory. |
| A symlink or hard link entry | archive entry "x" is a link, which bundles may not contain. |
| A device node, fifo or any type other than file or directory | archive entry "x" has unsupported type "3". |
| A GNU long-name or long-link extension | GNU long-name tar extensions are not supported; repack with ustar names. |
| A base-256 size field | base-256 tar sizes are not supported. |
| A size field that is not octal | tar entry has an unreadable size field ("..."). |
| An entry whose data runs past the end of the archive | archive entry "x" is truncated. |
| Not gzip at all, or no files in it | the bundle is not valid gzip: ... / the bundle contained no files. |
Two caps bound the damage, and both are on the unpacked side — a 4 KB archive can otherwise expand into gigabytes:
- 32 MB of total unpacked file bytes, reported as
the bundle expands past the 32 MB limit. - 2048 files, reported as
the bundle contains more than 2048 files.
Inside the plugin directory, the manifest may be at the archive root or inside exactly one wrapping directory — both are accepted, because tar czf bundle.tar.gz my-plugin/ produces the second shape. Anything deeper is not a plugin bundle: if there is no manifest at either level, installation fails with "the bundle has no plugin.json at its root or in a single wrapping directory." Directories are skipped, so two wrapping directories are ambiguous and therefore refused.
One more rule that catches people out: the manifest inside the bundle must declare the same id that was requested. Installing a bundle that says dev3d.something-else fails with "the bundle declares "..." but "..." was requested." That is what stops a listing from advertising one plugin and serving another.
A development loop that works
Put the directory in DEV3D_PLUGINS_DIR while you iterate: it is loaded from disk on boot and marked as bundled, which also means the console will not remove it for you. Restart, then read the log — validation problems and warnings are logged per plugin, under the scope plugin:<id> for warnings, before anything is activated. Then read the console record: status says loaded, disabled or error, error carries the message, and the contribution counts say what actually landed versus what you declared. A count of zero against something you are certain you declared almost always means the validator dropped those entries, and the warning beside the plugin names the index and the reason. For a code plugin, log one line from activate() and confirm it appears before you debug anything else; then grant a role the namespaced tool name, because a tool nobody is allowed to call is indistinguishable from one that was never registered.
What to check if it did not work
| Symptom | Most likely cause | What to do |
|---|---|---|
| No record for the plugin at all. | The directory is not directly under DEV3D_PLUGINS_DIR or DEV3D_PLUGIN_INSTALL_DIR — only immediate subdirectories are scanned — or it is not a directory. |
Flatten it. plugins/my.plugin/plugin.json, not plugins/group/my.plugin/plugin.json. |
status: 'error' with a message listing several field: message clauses. |
Manifest validation refused the plugin. Multiple problems are joined with semicolons. | Fix every clause. This is the reject path, so nothing in the plugin loaded. |
apiVersion is rejected as targeting a different API. |
The plugin's major version does not equal the host's. The host implements 1; only the major is compared, so 1.2 is fine and 2 is not. |
Set apiVersion to the host's major, or upgrade the host if you genuinely target a newer API. |
status: 'error' with "entry module ... does not exist" or "does not export activate()". |
entry is resolved relative to the plugin directory, so the file is missing or named differently; or the module imported but has no named activate export, which is what a CommonJS file or a default export produces. |
Export a named activate(api) from an ES module at exactly the path entry names, with no .. in it. |
| Half the declared models are missing and there is a warning beside the plugin. | The validator dropped individual entries — the keep-the-rest path. Each warning names the index and the reason. | Read the warning. A model entry needs id, providerId and a valid tier; a tier must be one of the five real ones. |
| A provider never appears as configured, or one with an http base URL is dropped. | It declares neither keyEnvVar nor keyless, so it cannot authenticate; or its URL is plain http somewhere other than the loopback address. |
Name the environment variable, or set keyless: true for a local runtime on loopback. |
| A role lists a tool but the tool never resolves. | allowedTools names the raw tool name, and only namespaced names exist in the registry. A granted name that does not resolve is silently filtered out. |
Use the namespaced form, e.g. dev3d_office_echo_echo. |
A panel renders blank, or its source is ignored. |
The widget kind is not one of the six, a required field is empty, or the source.url is not http or https — the scheme is not negotiable, because the server fetches it. |
Check the warning for dropped widget counts, and read the caps table above. A live panel must serve JSON with a widgets array over http(s). |
Settings you saved do not reach api.settings. |
settings was placed inside contributes, or a stored value's type no longer matches the field's declared type, so it was dropped on the write. |
Move settings to the top level of the manifest. Then re-save: a value of the wrong type is discarded rather than coerced. |
| A routing rule has no visible effect. | Rules only reorder candidates that already passed capability filtering, and an avoidModelIds entry is a −0.12 penalty rather than a ban. |
Check the routing decision and its reasons; if the model you avoided is dramatically cheaper, it can still win. |
| Disabling a code plugin did not stop its code. | Node cannot unload an ES module. Contributions and tools are withdrawn, subscriptions are dropped, deactivate() is called — the module itself stays resident. |
Restart to fully discharge it. Design deactivate() to release whatever the module allocated, since that is the only hook you get. |
| A bundle is refused for a link, a long name or a strange type. | The archive was produced by a tool that emits GNU extensions or preserved symlinks. | Repack portably. A plain tar czf of the directory contents, with no symlinks and no long paths, works. |
| A bundle is refused for being too big. | More than 32 MB unpacked, or more than 2048 files. Both caps are on the unpacked side. | Trim the payload. A plugin that needs more than this is carrying something that is not a plugin. |
Where to go next
Linked from
Did this page answer your question?