Author guide for the malleable-factory epic (WM-834). This page is the one reference for everything an extension can contribute; each follow-up ticket appends its section here rather than opening a new document.
An extension is one directory with one manifest, factory-extension.json,
that declares everything the directory contributes to a running factory. It
is the unit an operator installs and enables — "the mobile pack + the argent
adapter" is one extension, enabled with one line, not two things installed two
ways.
Today an extension can contribute:
- packs — agent definitions, schemas, prompts, event types, edges and
schedules, in the filesystem pack format of
kernel-and-packs.md; they go through the same pack loader with the same namespace, duplicate, pin andmutatingrules; - adapters — harness adapters satisfying the contract in
event-runtime-workers.md§2c; they are registered into the adapter registry with the extension's name as theirsource; - config — a JSON-schema for the extension's operator settings, validated
at load with defaults applied and shown read-only in
GET /configand Settings (§Config below); - hooks — decision-returning modules run at a named point of the runtime,
today
approve.before(immediately before each chain auto-approval); a fail-closed waterfall whose every decision is persisted (§Hooks below); - connectors — long-running processes the runtime starts with
serveand stops on shutdown; they talk to the outside world (Buzz, Telegram, a tracker) through a narrow loopback client and never hold a DB handle (§Connectors below); - panels — declarative
factory.panel-view/v1Overview tiles (event-runtime-artifact-views.md§2.6); data only, drawn by the web's existing artifact-view renderer, bound to an allow-listed loopback API endpoint (§Panels below); - harness — floor markdown, slash commands, skills and subagents that
build/emit.mjspackages for Claude Code, Codex, Gemini, Cursor and Pi (§Harness below). Hermes Agent is an event-runtime ACP adapter with its own.hermes/commandsand.hermes/agentstargets.shared/is the built-infactory/corepack.
The manifest also reserves views for the ticket that lands it. A
manifest that carries a reserved key loads its packs, adapters, connectors
and the rest and records a
"not supported yet" configuration anomaly for the rest — it is accepted, not
rejected, so an extension written for a later runtime still does what this one
understands.
Implementation: event-runtime/lib/extensions.mjs, schema
event-runtime/schemas/factory-extension.schema.json, fixture
event-runtime/test-support/extensions/sample/.
~/.factory/extensions/wattmind-mobile/
factory-extension.json # the manifest — required
pack/ # a filesystem pack (pack.json, pins.json, agents/, schemas/, …)
adapters/
argent.mjs # an adapter module (execute + SANDBOX_SUPPORT)
hooks/
no-infra-merges.mjs # an approve.before hook (id + default (ctx) => decision) (§Hooks)
connectors/
buzz.mjs # a connector (id + default start(ctx) => { stop, health }) (§Connectors)
config.schema.json # the shape of the extension's operator config (§Config)
panels/
blocked-tickets.panel.json # a factory.panel-view/v1 panel
harness/
floor.md # optional AGENTS.md floor block
commands/ # slash-command markdown
skills/ # skill folders (SKILL.md)
agents/ # custom-agent markdown (manifest key: subagents)
Nothing about the layout is fixed except the manifest's name and place: every contributed path is written in the manifest, relative to the manifest's directory, and must stay inside it.
{
"name": "wattmind/mobile",
"version": "1.0.0",
"description": "Mobile packs and the argent adapter",
"factory": { "min": "0.x" },
"contributes": {
"packs": ["./pack"],
"adapters": { "argent": "./adapters/argent.mjs" },
"config": { "namespace": "mobile", "schema": "./config.schema.json" },
"hooks": { "approve.before": "./hooks/no-infra-merges.mjs" },
"connectors": { "buzz": "./connectors/buzz.mjs" },
"panels": ["./panels"],
"harness": {
"floor": "./harness/floor.md",
"commands": "./harness/commands",
"skills": "./harness/skills",
"subagents": "./harness/agents"
}
}
}| Key | Required | Meaning |
|---|---|---|
name |
yes | publisher/extension, matching ^[a-z0-9-]+/[a-z0-9-]+$. Recorded as the source of every adapter the extension registers, so bun event-runtime/cli.mjs adapters can say where an adapter came from. |
version |
yes | Semver (MAJOR.MINOR.PATCH, optional pre-release/build). |
description |
no | Up to 200 characters, for listings. |
factory.min |
no | The oldest factory the extension was written for. Informational until the runtime carries a version; the loader records it and does not enforce it. |
contributes.packs |
no | Array of relative directories, each containing a pack.json. The pack's name and namespace come from its own pack.json (docs/kernel-and-packs.md § Pack format) — the policy entry names the extension, not the pack. |
contributes.adapters |
no | Object name → relative .mjs path. Names must match the adapter pattern ^[a-z][a-z0-9-]*$; the module must export execute and SANDBOX_SUPPORT. An extension may not replace an existing adapter (built-in or from an earlier extension). |
contributes.config |
no | Object { namespace, schema }: the name the extension's operator values are published under (^[a-z][a-z0-9-]*$, unique across loaded extensions) and the relative path of the JSON-schema those values must satisfy. See § Config. |
contributes.hooks |
no | Object hook point → relative .mjs path. The only point today is approve.before; an unknown key is a schema violation. The module must export a string id and a default (ctx) => decision function. See § Hooks. |
contributes.connectors |
no | Object name → relative .mjs path. Names match the adapter pattern ^[a-z][a-z0-9-]*$; the module must export a string id and a default start(ctx) → { stop, health }. See § Connectors. |
contributes.panels |
no | Array of relative directories; every *.panel.json directly inside is a factory.panel-view/v1 panel (§Panels). The manifest check proves the directories exist inside the extension; the registry validates each panel at load and skips a bad one alone. |
contributes.harness |
no | Object { floor?, commands?, skills?, subagents? }: relative file/directory of harness-neutral markdown emit packages (§Harness). The built-in pack is shared/ (factory/core). |
contributes.views |
no | Reserved. Accepted by the schema, ignored by the loader, reported as a configuration anomaly contributes.views is not supported yet. |
Unknown top-level keys and unknown contributes keys are schema violations.
Validate a manifest without loading anything:
bun event-runtime/cli.mjs extensions validate ~/.factory/extensions/wattmind-mobile
bun event-runtime/cli.mjs extensions validate @watt-mind/factory-ext-buzz
# wattmind/mobile@1.0.0: valid (1 pack, 1 adapter)validate checks the schema, that every contributed path exists and stays
inside the extension directory, and that adapter names, connector names and
hook points are well-formed. It does not load a pack, import an adapter,
hook or connector module, or read a panel document — running third-party
code is what enabling does, and panel documents are validated by the registry
at load (an invalid one is a /status anomaly).
There are two kinds of contribution, and the difference is what runs.
- Data-only packs, panels and harness markdown. A pack is JSON and prose:
definitions, schemas, prompts, routing maps. A panel is JSON too — an
endpoint the runtime already serves, a pointer and rendering hints; the
web loads no code for it and fetches only endpoints the runtime
allow-lists. Harness content is markdown (floor, commands, skills,
subagents) that
build/emit.mjspackages; loading it executes nothing. The kernel then holds packs to the configured-pack rules — its own namespace, no shadowing of built-ins, pinned prompts and schemas, nomutating: true— so a pack can add agents but cannot widen what an agent may do. This is the surface an agent may author (a dispatched ticket producing a pack or a harness command is ordinary data), and it is why the fixture pack is a copy oftest-support/packs/sample. - Operator-installed code. An adapter is an ES module the worker imports
and calls; a hook is an ES module
serveimports and calls inside the approval pass; a connector is an ES moduleservestarts after the registry loads and stops on shutdown. Enabling an extension that contributes any of them is running that code in the runtime process with its credentials. Only the operator enables extensions — by editingconfig/policy.yaml, a committed file — and nothing an agent does at runtime can add one. The registry still puts every adapter behind the sandbox seam (anunsupportedadapter is refused for a sandboxed definition before its code runs, WM-313/WM-837), and it refuses a module that does not satisfy the contract at load time rather than mid-run. A connector that failsstart()is the one exception to all-or-nothing: that connector is disabled with a configuration anomaly and the extension's other contributions stay loaded.
Two rules follow. Discovery is allow-listed, never scanned: the loader reads
only the path: directories and package: names policy.yaml lists, in that
order — dropping a directory into ~/.factory/extensions/ or installing an
npm package does nothing on its own. And a broken extension is
a configuration anomaly, not a crash: a missing or malformed manifest, a
pack the registry would refuse, or an adapter that fails the contract skips
that extension whole (nothing of it is registered, not even its good parts),
records why under /status.anomalies.configuration (visible in doctor, the
web UI's status, and extensions list), and lets every other extension load.
serve and work never fail to start because of a third-party manifest.
The one thing that does fail closed is a malformed extensions: block itself
— an operator typo in the allowlist, exactly like packs:.
Add it to config/policy.yaml — either a directory (path:) or an installed
npm package (package:). Both on one entry, or neither, is a configuration
anomaly and the entry is skipped. Nothing is auto-discovered from
node_modules.
extensions:
- package: @watt-mind/factory-ext-buzz # resolved from the factory root's node_modules
- path: ~/.factory/extensions/wattmind-mobile # must contain factory-extension.json
config: # optional; the shape is the extension's config schema (§Config)
simulator: iPhone-16
maxParallel: 2
- path: vendor/another-extension # relative paths resolve from the factory checkoutEach entry accepts either path (~ expands to the home directory) or
package (@scope/name or name, resolved with createRequire from the
factory root) and an optional config object. version on a package: entry
is display-only — the loader records the installed package.json version, it
does not pin or fetch. Anything else is an anomaly and the entry is skipped.
A package: that is not installed is an anomaly naming the npm i command,
never a crash. Entries load after the built-in root and after every packs:
entry, in policy order: packRoots handed to the registry is packs: first,
then each accepted extension's packs. Restart serve and the workers —
extensions are read at startup, alongside the registry.
Loaded rows (extensions list --json, and the loader snapshot /config
reads) carry source: "path" or source: "package", the resolved directory,
and for packages the installed version. A node_modules symlink is realpath'd
before the inside-the-dir check, so a contributed path cannot escape through
a linked package.
Inspect what loaded:
bun event-runtime/cli.mjs extensions list # name, version, pack/adapter counts, path; anomalies on stderr
bun event-runtime/cli.mjs extensions list --json # { extensions, anomalies }
bun event-runtime/cli.mjs adapters # extension adapters appear with SOURCE = the extension nameAn extension pack that duplicates a configured pack's name, or whose agents collide with an already-loaded namespace, is refused with the registry's own message naming both packs; fix the pack (or the order) and restart.
- Create the directory and
factory-extension.json; pick anameunder your publisher prefix. - Add packs in the pack format (
kernel-and-packs.md), each under its ownpack.jsonwith a non-emptynamespace, and write itspins.json(sha256:of each prompt and schema,kernel-and-packs.md§ Pins).update-pins --pack <name>reaches onlypolicy.yaml packs:entries today; extension packs are pinned by hand until that command learns about extensions. - Add adapters as
.mjsmodules exportingexecuteandSANDBOX_SUPPORT; the smallest conformant module isevent-runtime/test-support/extensions/sample/adapters/echo.mjs. - If the extension needs operator settings, write
config.schema.jsonand declarecontributes.config(§Config); give every setting adefaultwhere one makes sense. - If the extension gates unattended work, add an
approve.beforehook (§Hooks); the smallest conformant module isevent-runtime/test-support/extensions/sample/hooks/approve-before.mjs. - If the extension talks to an external system (Buzz, a tracker, a
notifier), add a connector (§Connectors); the smallest conformant module
is
event-runtime/test-support/extensions/sample/connectors/echo.mjs. - Add panels as
panels/<slug>.panel.json(§Panels); the fixture'spanels/open-proposals.panel.jsonis a complete one. - Add harness content as
contributes.harness(§Harness) when the extension ships slash commands, skills, subagents or a floor block. bun event-runtime/cli.mjs extensions validate <dir>until it is clean, enable it,extensions list, restart.
The fixture event-runtime/test-support/extensions/sample/ is a complete,
loadable example, and event-runtime/lib/extensions.test.mjs shows every
failure mode and what the anomaly for it says.
contributes.panels lists directories, relative to the manifest, whose
*.panel.json files are factory.panel-view/v1 panels — declarative
Overview tiles bound to one allow-listed loopback API endpoint and drawn by
the web's artifact-view renderer. The full format, the endpoint allow-list
and the rendering rules are in
event-runtime-artifact-views.md §2.6;
what is specific to extensions:
{
"format": "factory.panel-view/v1",
"name": "wattmind/mobile:blocked-tickets",
"title": "Blocked > 24h",
"source": {
"endpoint": "/tickets",
"query": { "state": "blocked" },
"path": "/tickets"
},
"refreshSeconds": 60,
"view": {
"sections": [
{
"path": "",
"as": "table",
"columns": ["identifier", "title"],
"formats": { "identifier": "issue" }
}
]
}
}- Names are
<publisher>/<extension>:<slug>— the extension's own name as prefix — and must be unique across built-ins, packs and every extension; a clash is a configuration anomaly for the later contributor and the earlier panel stays. - Origin.
GET /panelsreports each of these withorigin: "extension:<name>", and the tile shows that origin under its title. - Order. Extension panel directories load after every pack (built-in and
packs:), in policy order, into the same registry list. - Failure. A panel that does not validate — bad schema, an endpoint that
is not allow-listed, a
viewthe artifact-view vocabulary rejects, unparseable JSON — is skipped alone with a/status.anomalies.configurationline naming the file; the extension's other panels, packs and adapters still load. A directory that is missing or escapes the extension is a manifest error and skips the extension whole, like any other bad path. - Panels inside packs. A pack an extension contributes may also carry
panels/*.panel.json; those load with the pack (originpack:<namespace>), no manifest key needed. Usecontributes.panelsfor tiles that belong to the extension rather than to one pack.
Implementation: event-runtime/lib/panel-view.mjs (validation and
directory loading), lib/registry.mjs (loadRegistry({ panelRoots })),
lib/api-panels.mjs (GET /panels, PANEL_ENDPOINTS),
web/src/components/PanelGrid.tsx.
Shipped in WM-841. Implementation: resolveExtensionConfig,
applyConfigDefaults, getExtensionConfig and loadedExtensions in
event-runtime/lib/extensions.mjs; the extensions section of
event-runtime/lib/api-config.mjs; the Extensions section of
web/src/views/Settings.tsx; fixture
event-runtime/test-support/extensions/sample/config.schema.json.
An extension that needs operator-provided settings — API hosts, allow-lists,
thresholds — declares their shape in the manifest and the operator writes
the values in policy.yaml. Neither side is trusted on its own: the loader
checks the values against the shape before any of the extension's code runs.
Manifest:
"contributes": {
"config": { "namespace": "mobile", "schema": "./config.schema.json" }
}Schema (config.schema.json, relative to the manifest and inside its
directory):
{
"type": "object",
"additionalProperties": false,
"properties": {
"simulator": {
"type": "string",
"title": "Simulator",
"default": "iPhone-16",
"description": "booted before each run"
},
"maxParallel": {
"type": "integer",
"title": "Max parallel",
"minimum": 1,
"maximum": 4,
"default": 1
},
"apiToken": {
"type": "string",
"format": "secret",
"title": "API token",
"description": "resolved from FACTORY_EXT_MOBILE_API_TOKEN"
}
}
}format: "secret" is accepted only on fixed object properties declared in
properties, at any nesting depth. A secret under items, under
additionalProperties, or at the schema root is rejected when the extension
loads: the extension is disabled, its values are null, and an anomaly
records config schema ... declares format "secret" beneath ....
Rejected (dynamic location):
{
"type": "array",
"items": { "type": "string", "format": "secret" }
}Accepted (fixed property):
{
"type": "object",
"properties": {
"apiToken": { "type": "string", "format": "secret" }
}
}Values (config/policy.yaml) — non-secret settings only:
extensions:
- path: ~/.factory/extensions/wattmind-mobile
config:
maxParallel: 2Secrets are not written in policy.yaml. A property with
format: "secret" is resolved from process.env.FACTORY_EXT_<NAMESPACE>_<KEY>
(upper-snake of the config namespace and property path) or, if that is unset,
from ~/.factory/secrets.env (dotenv, mode 0600, loaded once at start;
FACTORY_EVENT_SECRETS_FILE overrides the path). A secret given in
policy.yaml is a configuration anomaly: the extension is disabled and the
message names the key and the env var to use.
# ~/.factory/secrets.env (chmod 600)
FACTORY_EXT_MOBILE_API_TOKEN=…What the loader does with them, in order:
-
Reads the schema. It must be a file inside the extension directory (
extensions validatechecks this too) and valid JSON. The keyword subset isevent-runtime/lib/schema.mjs's —type,enum,const,required,properties,additionalProperties,items,min/max*,pattern,description,title,format— plusdefault.formatis a closed enum:secret,uri,channel-id,ticket,duration,multiline,email. Unknown keywords and unknownformatvalues fail closed like every other contract in the runtime.formatis a UI hint excepturi(the string must parse as a URI) andduration(^\d+(ms|s|m|h|d)$). -
Applies defaults. Every
defaultunderproperties, recursively, is filled in where the operator gave nothing; a nested object property is only created when a default inside it produces something. With noconfig:in the policy at all the effective object is just the defaults, so an extension whose every setting has a default needs no operator input.format: "secret"properties never take a default from the schema or a value frompolicy.yaml. -
Resolves secrets. Each
format: "secret"property is filled fromFACTORY_EXT_<NAMESPACE>_<KEY>as above. The extension's own code sees the resolved string viagetExtensionConfig(). -
Validates the effective object with
schema.mjs validate. A violation is a configuration anomaly that disables the extension whole — nothing of it is registered, its adapters are not even imported — with a message naming the failing path:extension ~/.factory/extensions/wattmind-mobile: wattmind/mobile@1.0.0: config does not match ./config.schema.json — $.maxParallel: above maximum 4 (extension skipped)Two more faults are treated the same way: policy
config:values for an extension whose manifest declares nocontributes.config(the values would silently do nothing otherwise), and anamespaceanother loaded extension already uses.
The extension's own code reads the result by name:
import { getExtensionConfig } from "../../lib/extensions.mjs";
const cfg = getExtensionConfig("wattmind/mobile");
// { simulator: "iPhone-16", maxParallel: 2, apiToken: "…" }getExtensionConfig returns the effective object — defaults applied,
validated — or undefined when no extension of that name loaded (unknown, or
disabled by an anomaly). It reads the last loadExtensions run in the process,
which serve and work do once at start; a config change is a restart.
GET /config gains a section { id: "extensions", reload: "restart" } with
one item per extension the loader saw:
{ "name": "wattmind/mobile", "version": "1.0.0", "path": "…", "namespace": "mobile",
"reload": "restart", "schema": { … }, "values": { "simulator": "iPhone-16", "maxParallel": 2, "apiToken": { "set": true, "source": "env" } },
"anomaly": null }A disabled extension appears with values: null and its anomaly; an
extension that declares no config appears with namespace: null. The
section's entries mirror the same rows (key = namespace) so the Settings
search covers them. Settings renders it as the Extensions section — name,
namespace, version, path, a read-only SchemaForm of the effective values
(title = label, description = help, format = widget, enum / type /
minimum / maximum / default drive the control), a collapsed copy of the
schema, and the anomaly in place of the form when the extension is disabled.
Search also indexes every property's title and description. Read-only,
like every other Settings section: values change in policy.yaml or env,
then restart. Built-in sections (Policy, Nodes, …) should migrate onto the
same schema shape — WM-924.
UI hints. title is the field label (the property name is the fallback),
description is help text, format selects the widget:
format |
Widget (read-only) |
|---|---|
secret |
"set via env FACTORY_EXT_…" / "unset" — never a value |
uri |
link |
channel-id |
mono chip |
ticket |
mono chip with TicketHoverCard |
duration |
humanised (30s → "30 seconds") |
multiline |
<pre> |
email |
text |
Booleans render as a disabled toggle, enums as a disabled select, integers
with bounds as the number plus a range hint. A value equal to default is
marked.
Redaction rule. format: "secret" properties publish
{ set: true|false, source: "env"|"secrets.env"|null } instead of a value.
For schemas that predate format, every remaining value whose key matches
/token|secret|key|password/i — at any depth of the effective object — is
replaced by "[redacted]" (redactSecrets in lib/api-config.mjs). Empty
and null values are left as they are so "unset" stays visible. The schema
is published as written; do not put a real secret in a default.
Shipped in WM-842. Implementation: event-runtime/lib/hooks.mjs
(createHookRegistry, defaultHookRegistry, hookDecisionsFor,
hookDecisionCounts), the built-in
event-runtime/lib/hooks/builtin/escalation-labels.mjs, the call site in
event-runtime/lib/auto-approval.mjs (autoApproveChains → eligible →
dispatchSafe), fixture event-runtime/test-support/extensions/sample/hooks/.
The policy that gates unattended work — budget, worker cap, circuit breaker,
escalated/security labels, escalate-path intersections — lives in
lib/auto-approval.mjs. A hook is how an operator adds a gate of their
own ("never auto-approve merges touching infra/", "cap spend per repo")
without forking that file: a module the extension declares, imported by the
loader, asked for a decision at a named point. This is a hook seam in the
pi/deepseek-harness sense — typed, decision-returning interception — with the
factory's constraints on top: hooks are declared in a manifest (operator-
installed, in-process, never agent-authored), run as a waterfall, and
every decision is persisted so the audit trail stays complete.
One point exists today:
| Point | Evaluated | Deny becomes |
|---|---|---|
approve.before |
In autoApproveChains, immediately before each chain auto-approval, once the proposal has passed every structural check (predecessor, integrity, schema, policy allow-list) — for a dispatch, right after the recheck evidence hash is confirmed and before the escalate-path check; for every other event type, at the end of eligibility. The runtime guard (budget/cap/breaker) runs after the hooks. |
The proposal stays open with reason auto_approval_ineligible:dispatch_ineligible:<reason> (dispatch events) or auto_approval_ineligible:hook_denied:<reason> (all others). |
plan.before, execute.before, verify.after and outbox.before are
roadmap; declaring one is a schema violation until its ticket lands.
// contributes.hooks: { "approve.before": "./hooks/no-infra-merges.mjs" }
export const id = "wattmind/mobile:no-infra-merges"; // publisher[/extension]:name, unique across loaded hooks
export default async function approveBefore(ctx) {
// ctx: { proposal, spec, evidence, policy, repo, now, config }
const touched =
ctx.spec?.input?.plan?.flatMap((item) => item.paths ?? []) ?? [];
if (touched.some((p) => p.startsWith("infra/")))
return { decision: "deny", reason: "infra_paths_touched" };
return { decision: "allow" };
}ctx field |
What it is |
|---|---|
proposal |
{ id, runId, eventSource, eventId, eventType, createdAt, ttlSeconds } of the proposal about to be approved. |
spec |
The proposal's immutable RunSpec (agent, input, approvalPolicy, …), parsed. |
evidence |
For factory.dispatch.requested: the dispatch recheck evidence (ticket.labels, escalatePathIntersections, …) — the same object the built-in label check reads. null for every other event type. |
policy |
spec.approvalPolicy — { source: "chain", mode: "auto", eventType, … }. |
repo |
spec.input.repo, or null. |
now |
The pass's clock (ms since epoch); use it instead of Date.now() so replays stay deterministic. |
config |
The extension's effective config — getExtensionConfig(<manifest name>), defaults applied, validated at load (§Config). undefined for a built-in hook. Read settings from here, not from policy.yaml. |
The hook receives a deep copy of the context; mutating it changes nothing
downstream. The function may be sync or async. Its return value must be
exactly { decision: "allow" } or { decision: "deny", reason } where
reason is a short token ([A-Za-z0-9_.:/-]{1,120}) — it is embedded in the
proposal's reason string and shown in the UI, so keep it greppable
(infra_paths_touched, not a sentence).
- Waterfall, built-ins first. For a point, the runtime's own hooks run
first (in the order the runtime registers them), then extension hooks in
policy.yaml extensions:order. The firstdenyends the run; the hooks after it are not called. Anallowfrom every hook is an allow. - Fail closed. A hook that throws, rejects, returns anything other than a
well-formed decision, or does not answer within
timeoutMs(2000 ms) is adenywith reasonhook_error:<id>— the proposal stays open, never approved. A synchronous hook cannot be interrupted, so one that overruns the budget is still denied once it returns; write long checks async. Nothing a hook does — throw, hang, return garbage — can widen what would have been approved without it. - Registration is all-or-nothing per extension. Each hook module is
imported and contract-checked (
defaultfunction, stringid) before the extension is accepted; a module that fails, or anidanother loaded hook (built-in or extension) already uses, is a configuration anomaly that disables the extension whole — like a bad adapter. Hooks are registered only once the whole extension is known good, withsource: extension:<name>. EveryloadExtensionsrun replaces the previous run's extension hooks, so a removed extension's hook cannot linger past a restart. - A hook can only refuse. There is no
allowthat overrides a built-in deny, no reordering, no replacing the built-in hooks. The built-in escalation-label refusal (factory:escalation-labels) is the first hook ofapprove.beforeand behaves exactly as the inline check did before this seam existed:ai:escalated,type:security, or any label matching/security/ion the dispatch ticket →escalated_or_security.
Every decision — allow and deny alike, for every hook that ran — is appended
to hook_decisions, a table lib/hooks.mjs owns (created on first use, the
notify_log pattern; not core schema):
| Column | Meaning |
|---|---|
at |
ISO timestamp (the pass's clock) |
point |
approve.before |
hook_id, source |
The hook, and builtin or extension:<name> |
proposal_id, run_id |
What was being decided (run_id nullable for future non-proposal points) |
decision, reason |
allow / deny, and the deny reason (null on allow) |
duration_ms |
How long the hook took |
error |
The message behind a hook_error:* deny (throw, timeout, malformed value) |
Read it back:
GET /proposals/:id→{ proposal, hookDecisions: [...] }, oldest first — why this proposal was (not) auto-approved, hook by hook;GET /status→hooks.decisions24h:{ "<hook id>": { source, point, allow, deny } }over the trailing 24 h — a gate that is firing, or a broken extension hook denying everything, is visible from the status page anddoctor.
The fixture hook event-runtime/test-support/extensions/sample/hooks/approve-before.mjs
allows unless the extension config says greeting: "deny" (proving
ctx.config) or the dispatch ticket carries sample:deny; the sibling
files there (throws.mjs, hangs.mjs, async-deny.mjs, no-id.mjs,
no-default.mjs) exercise each failure mode in hooks.test.mjs and
extensions.test.mjs.
Shipped in WM-919. Implementation: event-runtime/lib/connectors.mjs
(validateConnectorModule, createConnectorClient, startConnectors,
stopConnectors, connectorStatus), load-time import in
event-runtime/lib/extensions.mjs, start/stop in event-runtime/cli/serve.mjs,
status projection in event-runtime/lib/api-status.mjs, fixture
event-runtime/test-support/extensions/sample/connectors/echo.mjs.
A connector is how an extension talks to an external system without
editing the kernel: Buzz, a tracker, a notifier that is not the hard-wired
Telegram path in lib/notify.mjs. It is operator-installed code — the
same trust class as adapters and hooks, allow-listed in policy.yaml, never
scanned. The connector talks to the runtime only through a narrow client;
it never receives a DB handle and cannot mutate the registry.
"contributes": {
"connectors": { "buzz": "./connectors/buzz.mjs" }
}Names match the adapter pattern ^[a-z][a-z0-9-]*$. Paths are relative to
the manifest and must stay inside the extension.
// contributes.connectors: { "echo": "./connectors/echo.mjs" }
export const id = "factory/sample:echo"; // publisher[/extension]:name
export default async function start(ctx) {
// ctx: { config, secrets, client, log, signal }
const unsubscribe = ctx.client.inbox.subscribe((event) => {
ctx.log(`inbox ${event.type} ${event.item?.id ?? ""}`);
});
ctx.signal.addEventListener("abort", () => unsubscribe(), { once: true });
return {
async stop() {
unsubscribe();
},
health() {
return { ok: true, detail: "subscribed", lastEventAt: undefined };
},
};
}ctx field |
What it is |
|---|---|
config |
The extension's effective config with secret values stripped. Read settings from here, not from policy.yaml. Never contains secret values. |
secrets |
Values the loader resolved from FACTORY_EXT_<NAMESPACE>_<KEY> (process env, then ~/.factory/secrets.env) for every format: "secret" property, plus remaining keys matching /nsec|token|secret|key|password/i. Absent keys are missing, not empty strings. |
client |
The narrow loopback client (below). |
log |
(message) => void — prefixed connector <ext>/<name>: on the serve log. |
signal |
An AbortSignal. Aborted when serve shuts down, when start() overruns 10 s, and when that connector is otherwise stopped. |
start may be sync or async. Its return value must be { stop, health }:
| Method | Contract |
|---|---|
stop() |
May be async. Isolated: a throw does not fail shutdown or other connectors. |
health() |
Sync. { ok: boolean, detail?: string, lastEventAt?: string }. A throw is reported as ok: false with the message. |
The connector never holds the database. client is the only runtime surface:
| Method | What it does |
|---|---|
inject(envelope) |
Same intake as cli inject (admitExternalEvent). Overwrites source to connector:<ext>/<name>. The event follows the normal planner/approval path — a connector cannot approve a proposal it injected. |
inbox.list({ status }) |
Open/acked/resolved/all inbox items. |
inbox.get(id) |
One inbox item, or null. |
inbox.decide(id, response, { actor }) |
Async (returns a promise). Records decidedBy: "connector:<ext>/<name>:<actor>" (unknown when actor is omitted). |
inbox.markDelivered(id, delivery) |
Shallow-merges delivery onto the inbox row's delivery_json (e.g. { buzz: { eventId, postedAt } }) without deciding the item. Survives process restart. Does not clobber responseHistory. |
inbox.subscribe(cb) |
cb({ type, item, at }) on new-item / changed. Returns an unsubscribe function. |
proposals.get(id) |
One proposal, or null. |
runs.get(id) |
One run (runId, state, attempts, spec, result, …), or null. result is the accepted artifact only — never the full result_json row (no receipts, no prompts). |
runs.subscribe(cb) |
cb(event) for a lifecycle transition committed by this process. Process-local: a transition the worker (a separate process, OPS-233) commits never reaches it. Returns an unsubscribe function. |
runs.tail(sinceSeq) |
{ events, cursor } — every lifecycle transition committed after sinceSeq, read from the durable journal. Works across processes; re-poll with the returned cursor to keep tailing. Prefer this over runs.subscribe for anything that must not miss a worker-committed transition. |
runs.cursor() |
The current journal seq, for establishing a starting cursor without replaying history on connector start. |
There is no approve, no registry write, no raw SQL.
- Load is all-or-nothing; start is not. Each connector module is imported
and contract-checked before the extension is accepted. A module that fails
the contract (missing
defaultfunction, badid, import error) disables the extension whole — like a bad adapter. After the registry loads,servecallsstart()with anAbortSignal. Astart()that throws, rejects, or does not return{ stop, health }within 10 seconds recordsconnector <ext>/<name> failed to start: <msg>under/status.anomalies.configurationand leaves the extension's other contributions loaded. Connectors are the one contribution that may fail independently. Other connectors of the same extension still start. - Connectors only egress from live. The loader imports and registers
connector modules in every environment, but only invokes their real
start()function whenenvironmentName() === "live". Worktree, demo, and test runtimes instead logconnector <extension>/<name>: not started: non-live environmentonce and expose healthy connector status with detailnot started (non-live env). This is environmental: do not change a copiedpolicy.yamlto disable connectors in a worktree. - Secrets never sit in
policy.yaml. Everyformat: "secret"property (WM-920) is read fromFACTORY_EXT_<NAMESPACE>_<KEY>(upper-snake) and never from the policy entry. A secret present inpolicy.yamldisables the extension. Until a schema declaresformat: "secret", keys matching/nsec|token|secret|key|password/iare treated as secrets forctx(moved out ofconfigintosecrets)./configpublishes{ set, source }for declared secret fields, never the value. - Attribution.
injectstampssource: "connector:<ext>/<name>".inbox.deciderecordsdecidedBy: "connector:<ext>/<name>:<external actor>". - Status.
connectorStatus()/attachConnectorStatus()projectconnectors: [{ extension, name, ok, detail, lastEventAt, startedAt }]. Start-failure anomalies already appear on/status.anomalies.configurationviaregistry.anomalies. Wiring theconnectorsarray ontoGET /status, one doctor line per connector, and a CONNECTORS column onextensions listis a follow-up — those files sit outside this ticket's Owned Paths.extensions list --jsonalready includes theconnectorsarray the loader records.
The fixture event-runtime/test-support/extensions/sample/connectors/echo.mjs
subscribes to inbox writes, logs them, and exposes health so
extensions.test.mjs, connectors.test.mjs and api-status.test.mjs can
watch load/start/stop/anomaly/secrets without an external network.
Shipped in WM-921. Implementation: extensions/buzz/ (self-contained so it
can later publish as @watt-mind/factory-ext-buzz; the package resolver is
WM-922).
The factory appears in Buzz as an agent. Inbox items post to #general on
https://watt-mind.communities.buzz.xyz; 👍 / 👎 / 💤 (and numbered
reactions) plus thread replies map onto inbox.decide; @factory dispatch
and @factory status are a closed command grammar. Telegram stays the
blocker channel until a real BLOCKED push has been observed end-to-end.
Layout:
extensions/buzz/
factory-extension.json # name: wattmind/buzz
config.schema.json # namespace buzz; format: secret for nsec + auth tag
connectors/buzz.mjs # id wattmind/buzz:buzz
panels/buzz.panel.json # Overview tile bound to /inbox
README.md # keygen + secrets.env
Enablement (secrets never in policy.yaml):
extensions:
- path: extensions/buzz
config:
channel: "91572011-2505-5288-b6f5-4a7d74abf106" # #general# ~/.factory/secrets.env (chmod 600)
FACTORY_EXT_BUZZ_AGENT_NSEC=nsec1…
FACTORY_EXT_BUZZ_AUTH_TAG=["auth","<owner-pubkey>","<conditions>","<sig>"]Mint the key and the NIP-OA tag with the WM-905 script (buzz.py keygen /
buzz.py auth-tag); the owner nsec is read once and never stored. Restart
serve. The connector speaks REST (POST /events, POST /query) with
NIP-98 + x-auth-tag, the same path as buzz-cli. Failed posts sit on a
bounded in-memory queue; ingress resumes with since.
Shipped in WM-849. Implementation: contributes.harness on
factory-extension.json; collectHarnessRoots, harnessRootFor in
event-runtime/lib/extensions.mjs; build/emit.mjs emits every loaded
root; the built-in pack is shared/factory-extension.json.
Harness content is markdown the emit pipeline packages for every coding
agent the factory supports — the AGENTS.md floor, /factory-* slash
commands, skills, and custom subagents. Hermes Agent is supported by the
event runtime's ACP adapter; its command and subagent content is materialized
under .hermes/, while skills are not yet supported. It is data, not
in-process code: loading a harness contribution executes nothing. Enabling it is
the same allow-list as every other contribution (policy.yaml extensions:); shared/ is the exception, the built-in factory/core
pack that emit always includes first so plugins/core/** and dist/** stay
byte-identical with the historical layout.
"contributes": {
"harness": {
"floor": "./floor.md",
"commands": "./commands",
"skills": "./skills",
"subagents": "./agents"
}
}Every key is optional. Paths are relative to the manifest and must stay
inside the extension; floor must be a file, the others directories. A
missing or escaping path is a manifest error and skips the extension
whole, like any other bad path.
Emit. bun build/emit.mjs calls collectHarnessRoots({ builtin: shared/ })
then writes each pack:
| Pack | Claude plugin | dist/ (Codex, Gemini, Cursor, Pi) |
|---|---|---|
factory/core (shared/, always first) |
plugins/core/ |
historical paths (dist/codex/skills/ticket-spec/, …) |
| any other contributing extension | plugins/<publisher-extension>/ |
nested under the same slug (dist/codex/skills/<slug>/…); flat Cursor/Pi filenames are prefixed <slug>- |
--sync-floor still splices only the core floor into configured repos'
AGENTS.md. A third-party floor is emitted as dist/AGENTS.floor.<slug>.md
and is not auto-spliced.
Collision. A non-core pack may not take plugin name core or reuse
another pack's slug or name. The later contributor's harness is skipped
(emit records an anomaly and still writes every other pack; loadExtensions
skips that extension whole, same as an adapter name clash). Discovery stays
allow-listed: dropping a directory into ~/.factory/extensions/ does
nothing until policy.yaml names it.
Trust. Harness markdown is the same class as packs — an agent may author it — with the operator enablement gate in front. It is not an adapter: emit never imports third-party JavaScript.
Distribute an extension as an npm package so another factory install enables
it with npm i @watt-mind/factory-ext-<name> plus one policy.yaml line, instead
of cloning a directory. An extension is already a self-contained directory
with one manifest, so the package is the extension root — the loader
resolves package: to that directory (§Enabling).
| Field | Value |
|---|---|
name |
@watt-mind/factory-ext-<name> (the @watt-mind org). Unscoped names resolve too; this is the published convention. |
files |
Whitelist: factory-extension.json plus every contributed path the manifest names (pack/, adapters/, connectors/, hooks/, panels/, config.schema.json, harness dirs). Do not ship tests, fixtures, or the factory checkout. |
peerDependencies |
None. An extension imports only from the runtime contract via ctx (hooks, connectors, adapters). It must not import event-runtime/lib/* — that is also why connectors receive a client object instead of a DB handle. |
engines.bun |
The bun range the extension was tested against (the factory itself is >=1.3). |
keywords |
Must include "factory-extension". |
package.json may point the loader at a subdirectory with
factory.extension: "./ext" when the package root is not the extension root.
That path is realpath'd and must stay inside the package.
bun event-runtime/cli.mjs extensions validate <dir|package>
bun event-runtime/cli/extensions.mjs pack <dir>pack runs validate, then npm pack --dry-run, and lists the files that
would ship. Use it before the first publish.
.github/workflows/publish-extension.yml is a manual workflow_dispatch
with an extension input (repo-relative directory). It validates, runs
tests under that directory when they exist, and
npm publish --provenance --access public. The NPM_TOKEN repository
secret must be set — if it is missing the workflow fails loudly rather than
publishing unauthenticated. The operator adds the token to the @watt-mind
org; the workflow does not create it.
Status: design — nothing built. Tracking: this ticket (#854, migrated from WM-846); implementation lands as follow-up tickets under WM-834, filed once this design is ratified.
docs/kernel-and-packs.md §Pins ends with a flat rule: configured packs are
read-only and may not declare an agent with mutating: true
(event-runtime/lib/registry.mjs, WM-468 decision 4). That is the right
default — a pack is content an agent can author (§Trust model above) — but it
means a shipped loop can only ever propose, never apply, even for the
narrow, deterministic remediation work the kernel's own bare namespace
already does (the keephq.disk-alert.raised closed-action-registry flow,
docs/event-runtime.md §11/§15, OPS-208). This section designs the opt-in
exception: a named, pinned, scope-limited grant that lets one specific
configured pack cross that line, without weakening it for every other pack
and without changing what "mutating" is allowed to mean.
Three shapes were considered:
- Option A — never. Keep the blanket refusal; every mutating workflow stays built-in. Simplest, and the one this document does not recommend: it forces every operator who wants a shipped remediation loop (not just the factory's own) to fork the kernel instead of installing a pack.
- Option B — explicit per-pack policy grant with named scopes.
policy.yaml, operator-only, names the pack, the capability scopes it may exercise, and the pinned content hash the grant applies to. Recommended — it reuses every enforcement mechanism this document andevent-runtime/lib/registry.mjs(lines 579-615) already have (closed-by-construction admission, watched approval, content pins,approve.beforehooks) instead of adding a new one, and it keeps the default (no grant) identical to today. - Option C — code extension tier only. Require mutating work to arrive as
an adapter or connector (§Connectors), never a pack agent. Rejected: it
throws away the closed-action-registry and closed-argv admission forms that
registry.mjsalready treats as enforceable by construction for the built-in namespace — those are pack-shaped (agents/*.jsonwithactionRegistry/hosts/execor a fixedcommand), not adapter-shaped, and duplicating them as adapters would mean re-deriving the same fixed-template safety property in hand-written code instead of declared data.
A grant lifts the pack-origin refusal at registry.mjs's WM-468 check for
one named pack. It changes nothing else in that function: every mutating
agent definition in a granted pack still has to pass the same
registry.mjs (lines 579-615) closed-by-construction test every built-in mutating definition passes today
— closed argv, closed action registry, closed item list, or a tier-2
worktree agent under the dispatch coordination design
(docs/event-runtime-dispatch.md §5–§7). An LLM-driven pack agent that is
none of those four shapes is refused exactly as it is today, grant or not.
The grant answers may this pack's agents be admitted for evaluation at all;
it does not relax what evaluation demands of them.
A grant is an addition to a pack's existing packs: entry
(docs/kernel-and-packs.md §Enabling a pack) or an extension's contributes.packs
entry (## Enabling an extension, above) — never a new top-level allowlist, so a pack's
mutating status is visible at the same place its namespace already is:
packs:
- name: ops-disk
path: packs/ops-disk
namespace: ops-disk
mutatingGrant:
scopes: [shell.remediate]
pin: sha256:… # of the pack's full pins.json content set, §Content pins below| Key | Meaning |
|---|---|
mutatingGrant.scopes |
Non-empty array of named capability scopes (below). An agent definition whose closed shape names a host, action, or capability outside the granted scopes is refused at load — the analogue of def.hosts/def.repos allow-lists, now checked against the grant instead of only the definition. |
mutatingGrant.pin |
The pack's full content pin hash (§Content pins). A grant whose pin does not match the pack's current update-pins --pack output is refused as a configuration anomaly — the pack loads as a read-only pack (its non-mutating agents still register) rather than failing the whole extension, so a routine content change degrades safely to the pre-grant default instead of taking the pack down. |
Named scopes, closed at four for this design (new scopes are their own ticket, never a free-form string a pack can invent for itself):
| Scope | Unlocks |
|---|---|
git.push |
A closed-argv or closed-item-list definition whose fixed template pushes to a repo remote. |
pr.merge |
A closed-argv or closed-item-list definition whose fixed template merges or closes a pull request. |
shell.remediate |
A closed-action-registry definition (actionRegistry/hosts/exec) — the OPS-208 shape, extended to granted packs. |
tracker.write |
A closed-argv or closed-item-list definition whose fixed template writes to the issue tracker (state, comment, label). |
A definition may combine shapes only within its own scopes; a pack agent
requesting a capability the grant does not name fails registry admission with
a message naming the pack, the agent, and the missing scope — the same
refusal shape as an unmapped model_tier or malformed repos entry.
- Default is unchanged. No
mutatingGranton an entry means exactly today's behavior:mutating: truein that pack is refused. Nothing about loading a pack or an extension implicitly grants anything. - Operator-only, never agent-writable.
mutatingGrantlives inpolicy.yaml, the same trust boundary asextensions:,contributes.hookswiring, andformat: "secret"resolution — an agent authoring pack content (§Trust model) can propose a pack that asks for scopes, but only the operator's own edit topolicy.yamlgrants them. - Approval is never widened. A grant changes admission, not approval.
Every mutating run from a granted pack still flows through the same
watched-proposal gate as
dispatch@1and the OPS-208 remediation node — structural checks, thenapprove.beforehooks (§Hooks above), then the runtime guard — andapproval: autois still earned per-edge (docs/event-runtime-dispatch.md§7), never conferred by the grant itself. - Permanent never-auto-apply list.
git.pushandpr.mergescopes may never reachapproval: auto, full stop — a new invariant this design proposes, following the precedent ofship-apply, whichevent-runtime/lib/auto-approval.mjs'sNEVER_AUTO_APPROVEset already excludes structurally from any auto-approval path (merge-applyis presently allowlistable viaconfig/policy.yaml, unlikeship-apply). A policy entry that tries to mark agit.push- orpr.merge-scoped edgeautois a configuration anomaly, not a silent downgrade to watched — the operator's intent was clearly wrong and should be visible as such. approve.beforehook required. A granted pack must have at least oneapprove.beforehook active in the runtime (the built-infactory:escalation-labelshook counts) — amutatingGrantwith zero hooks registered anywhere is refused at load. A grant can widen what a pack may attempt; it can never remove the one seam an operator has for saying no to a specific proposal shape.- Content pins are load-bearing for admission, not just audit. See below.
pins.json today hashes only prompt and schema paths (agents/*.md,
schemas/*.input.json, schemas/*.output.json) — the read-only pack's
tamper tripwire is a load-time check, and a mismatch already fails registry
startup closed (docs/kernel-and-packs.md §Pins and permissions). That is
not enough for a granted pack, because the enforcement surface for a mutating
definition is not its prompt — it is actionRegistry, hosts, exec, and
command, none of which pins.json covers today. A granted pack's pin set
must extend to cover every mutating agent definition's JSON file in full
(agents/*.json, not just its prompt), so that rotating a registered remote
command or adding a host is exactly as tamper-evident as editing a prompt is
today. mutatingGrant.pin is the hash of that extended set;
update-pins --pack <name> gains the same extension so re-pinning after a
legitimate change is one command, and the operator re-authors the grant's
pin value deliberately — the grant does not silently follow a content
change the way a read-only pack's registration does.
extensions list (and packs where it exists) gain a MUTATING GRANT column:
the granted scopes, whether the live pin matches (ok / stale), and
whether the required approve.before hook is present. GET /config's pack
entries carry the same three fields. A stale pin or a missing hook is a
/status.anomalies.configuration line naming the pack — visible in doctor,
/status, and Settings — and demotes that pack to read-only rather than
failing its whole extension, consistent with every other configuration
anomaly in this document.
None of the above is built. Once this design is ratified, the follow-up work — filed as its own tickets under WM-834, no numbers reserved by this document — is at least:
registry.mjs: parse and enforcemutatingGrant— scopes, the four admission shapes checked against granted scopes, and the pin match.kernel-and-packs.md§Pins: extendpins.json/update-pins --packto cover mutating agent definitions in full, not just prompts and schemas.auto-approval.mjs/ policy schema: refuseapproval: autoon agit.push- orpr.merge-scoped edge as a configuration anomaly.api-config.mjs+ Settings +extensions list: surface grant scopes, pin state, and hook presence.- A first real consumer to prove the design against — the OPS-208
keephq.disk-alertremediation reimplemented as ashell.remediatepack instead of a built-in definition — before any third-party grant is accepted.
kernel-and-packs.md— the pack format and the kernel's admission rules, which extension packs inherit unchangedevent-runtime-workers.md§2c — the adapter contract and registryarchitecture.md— where extensions sit in the design