Plugins & Capabilities
Plugins let you package a slice of functionality — its handlers, jobs, tools, and UI — as a self-contained unit that Arcway discovers, loads, and wires into the rest of the app. Plugins are first-clas
Plugins let you package a slice of functionality — its handlers, jobs, tools, and UI — as a self-contained unit that Arcway discovers, loads, and wires into the rest of the app. Plugins are first-class: they register through the same primitives as your application code (the job runner and callback system), and they compose with each other through a capability graph rather than by importing each other directly.
The plugin directory
Each plugin is a package under a plugins directory (plugins/ by default). A plugin is any subdirectory whose package.json carries an "arcway" block — its manifest — with everything else discovered by folder convention:
plugins/
web-search/
package.json # { "name": "web-search", "arcway": { … } } — the manifest
exports.js # optional — callable handlers exposed through ctx.plugins
jobs/ # optional — plugin jobs
callbacks/ # optional — inbound callback handlers
capabilities/ # optional — one file per provided capability
hooks/ # optional — lifecycle hooks (onLoad/onEnable/onDisable)Discovery scans every configured directory for these packages. Nothing is loaded until the plugin system is enabled.
The manifest
The manifest is the plugin's contract. It lives in the "arcway" block of the plugin's package.json; id, name, and version come from the package's own fields:
// plugins/web-search/package.json
{
"name": "web-search", // → plugin id (lowercase kebab-case)
"version": "1.0.0",
"arcway": {
"enabled": true, // optional; default true (false = ship but disabled)
"provides": ["search"], // capabilities offered to other plugins (optional)
"requires": {}, // capabilities needed from other plugins (optional)
},
}Handlers, jobs, callbacks, capability factories, and lifecycle hooks are all code, so they live next to package.json as exports.js, jobs/, callbacks/, capabilities/, and hooks/ rather than in the manifest. The manifest itself stays pure data.
| Field | Meaning |
|---|---|
id / name / version | Taken from the package's own package.json fields; id is the package name (lowercase kebab-case). |
enabled | Whether the plugin is active. Defaults to true — set false to ship a plugin but keep it off. |
provides | Capability names this plugin makes available to others (optional). |
requires | Capabilities this plugin depends on; resolved from providers at load time (optional). |
Anything else you put in the "arcway" block is preserved and passed through untouched — Arcway validates only its own fields and ignores the rest, so your app can attach its own metadata to a plugin without the framework caring.
The manifest is validated at load time: id must be kebab-case and version is required. Invalid manifests fail the boot loudly rather than loading a half-formed plugin.
Enabling plugins
The plugin system is configured in arcway.config.js:
export default {
plugins: {
enabled: true, // turn the loader on (default: false)
dirs: ['plugins'], // directories to scan (default: ['plugins'])
},
};When plugins.enabled is false, the loader does nothing — discovery doesn't run and nothing is mounted. When it's true, every discovered plugin is active unless its manifest sets enabled: false, or you override it through an enablement map:
plugins: {
enabled: true,
enablements: { 'web-search': true, 'experimental-thing': false },
}An explicit entry always wins over the plugin's own enabled flag, so you can force a single plugin on or off regardless — handy for rolling something out, or killing it, without touching the plugin itself.
Contributing handlers
Plugins are code-only: they do not own HTTP routes, and Arcway does not mount a plugin routes/ directory. The app owns every public URL and calls plugin handlers through ctx.plugins.get(id):
// plugins/web-search/exports.js
export default {
loadSettings: async (ctx, workspaceId) => {
return loadSettings(ctx.db, workspaceId);
},
};// api/workspaces/[workspaceId]/search-settings.js
export const GET = {
auth: 'session',
handler: async (ctx, { params }) => {
const settings = await ctx.plugins.get('web-search').loadSettings(params.workspaceId);
return { data: settings };
},
};This keeps routing, auth, request validation, and URL design in application code while plugins remain reusable implementation units. For inbound external traffic such as OAuth redirects or provider webhooks, use Arcway's callback primitive: register a plugin callback handler and route the external service through the single framework-owned /_system/callback endpoint.
Contributing jobs
Jobs discovered from the plugin's jobs/ directory follow the normal job shape and are namespaced plugins/<id>/<name>, so two plugins can both define a cleanup job without clashing.
Persistence
Plugins don't own database tables or ship schema migrations — letting a plugin mutate the shared schema would break the isolation that code-only handlers and the capability graph otherwise guarantee. Instead, a plugin persists its data through the framework's per-plugin key-value store, scoped to the plugin (encrypt sensitive values like API keys or OAuth tokens with ctx.vault). Anything that genuinely needs its own relational tables belongs in application code, not a plugin.
Capabilities
Sometimes one plugin owns a shared managed resource that several others use — a browser pool, a rate limiter, an embeddings client. Rather than each plugin spinning up its own, one plugin provides a named capability and the others require it; Arcway resolves the wiring through a capability graph, so consumers never import the provider directly.
A provider supplies a factory for each capability it offers as capabilities/<name>.js (default export). The factory is called per request with the request's identity, so the capability can be request-scoped:
// plugins/core-host/capabilities/browser.js
export default ({ ctx }) => ({
newPage: () => ctx.host.pool.newPage(),
});with "provides": ["browser"] declared in the package.json arcway block.
A consumer declares what it needs in requires, and the resolved capability is injected onto ctx under its name:
// in an app route or job handler
const page = await ctx.browser.newPage();Required capabilities simply appear as additional properties on the same ctx you already use (ctx.browser, …).
Capabilities are for shared resources, not secrets. A plugin's own API keys or OAuth tokens belong in its per-plugin KV store, not behind a shared capability — keep plugins self-contained unless they genuinely share a managed resource.
The capability graph
At boot, Arcway builds a graph from every enabled plugin's provides/requires and:
- Orders loading topologically, so a provider is ready before its consumers.
- Resolves each requirement to exactly one provider. A capability may be provided by only one plugin; a duplicate is an error.
- Fails fast on a missing provider. If an enabled plugin requires a capability nobody provides, the boot throws rather than starting in a half-wired state.
- Cascades disablement. Disabling a provider marks its capability unavailable; consumers that require it surface a
CapabilityNotAvailableerror (missing providerorprovider disabled) before their handler runs, instead of failing deep inside business logic.
Because requirements are matched by capability name rather than plugin id, consumers stay loosely coupled — you can swap the plugin that provides browser without touching anything that consumes it.
Lifecycle hooks
Optional lifecycle hooks let a plugin run setup and teardown as its state changes — each is a hooks/<name>.js file (default export):
hooks/onLoad.js— once, when the plugin is first loaded.hooks/onEnable.js— when the plugin transitions to enabled.hooks/onDisable.js— when the plugin is disabled.
Hooks are async and receive the application context, so a plugin can warm a cache, register an external webhook, or release resources at exactly the right point in its lifecycle.
In development, Arcway watches configured plugin directories. When a plugin file changes, Arcway rebundles the whole plugin as one esbuild artifact, reloads that plugin and its transitive dependents through the capability graph, then reloads plugin jobs. App routes keep calling the current plugin handler through ctx.plugins.get(id). This intentionally resets in-memory module state for the reloaded plugins; durable state should live in the plugin's persisted stores instead.