| name | ax-conventions |
| description | Use when writing or modifying any AX plugin or hook — covers the five invariants (transport/storage-agnostic hooks, no cross-plugin imports, no half-wired plugins, one source of truth, capabilities explicit and minimized), the plugin manifest format, hook bus mechanics, the boundary review checklist, and common patterns |
AX conventions
The architecture spec is docs/plans/2026-04-22-plugin-architecture-design.md — read that for rationale. This skill is the operational summary: the rules, the shapes, the patterns.
The five invariants
1. Hook surface is transport-agnostic and storage-agnostic
No git/sqlite/k8s/postgres/gcs vocabulary in hook payloads. If a payload field name only makes sense for one backend, it leaks.
Bad:
'workspace:commit' (ctx, { sha, branch, bundle, parentSha }) → { newSha }
Every field leaks git semantics. A GCS backend couldn't implement this.
Good:
'workspace:apply' (ctx, { changes, parent, reason }) → { version, delta }
version and parent are opaque WorkspaceVersion tokens. Git makes them SHAs; GCS makes them manifest object names. Neither leaks.
2. No cross-plugin imports
Plugins talk through the hook bus only. Enforced by eslint.config.mjs.
Bad:
import { writeCredential } from '@ax/credentials';
await writeCredential(name, value);
Good:
await hooks.call('credentials:write', ctx, { name, value });
The only @ax/* imports allowed inside plugin code are @ax/core (kernel, everyone depends on it) and @ax/test-harness (test-only). The CLI and presets are exempt — they legitimately wire plugins together.
3. No half-wired plugins
A plugin is either fully registered + tested + reachable from the canary acceptance test, or it doesn't merge. No "wire this up later" PRs.
Why: v1 accumulated half-wired traps (NATS bridge not called, k8s-pod sandbox incompatible with all-in-one server). They confused readers, drifted from the rest of the system, and represented work that looked done but wasn't.
4. One source of truth per concept
If two plugins both hold state about the same thing (skills, tools, sessions, sandbox pods), one of them is wrong. Coordinate through service hooks, not shared rows.
Bad: sandbox-k8s tracks pods in-memory AND storage-postgres has a sandbox_pods table, both updated separately. They will drift.
Good: sandbox-k8s owns the concept and exposes sandbox:lookup(id). If it needs durability, it calls storage:set — the table is an implementation detail, not a shared resource.
Corollary: no foreign keys across plugin boundaries. Each plugin's schema evolves independently.
5. Capabilities are explicit and minimized
Every plugin, tool, IPC handler, and sandbox boundary grants the smallest set of capabilities (filesystem paths, network reach, process spawn, env access, untrusted-input handling) that it needs — no more. Untrusted content (model output, tool output, user input crossing a trust boundary, third-party plugin code) is treated as untrusted at every hop.
The whole point of v2 over openclaw is that we're the secure one. If a hook surface, IPC action, or plugin grants more reach than it strictly requires, that's the bug.
Bad:
'tool:execute' (ctx, { name, args }) → { output }
Good:
'tool:execute' (ctx, { name, args }) → { output: UntrustedString }
Fires on: sandbox boundaries, IPC transport, plugin loading, hook payloads carrying untrusted content, new dependencies, code that uses caller-provided file paths / spawns processes / opens network connections.
What to do: invoke the security-checklist skill. It walks three threat models (sandbox escape, prompt injection, supply chain) and produces a structured PR security note.
Plugin manifest format
Each plugin instance exposes an in-code manifest object, validated by PluginManifestSchema in @ax/core (packages/core/src/plugin.ts):
export function createWorkspaceGitPlugin(config: WorkspaceGitConfig): Plugin {
return {
manifest: {
name: '@ax/workspace-git',
version: '0.0.0',
registers: ['workspace:apply', 'workspace:read', 'workspace:diff'],
calls: [],
optionalCalls: [
],
subscribes: [],
},
init({ bus }) {
},
};
}
registers — service hooks this plugin implements. Exactly one plugin may register each service hook; collision is a boot-time error.
calls — service hooks this plugin requires. Core uses this for (a) cycle detection at boot, (b) failing fast on missing services, (c) generating a compatibility matrix. A calls entry with no producer is a boot-time missing-service error.
optionalCalls — service hooks this plugin can call but degrades gracefully without. Each entry is { hook, degradation }, where degradation is a required human-readable note describing what the plugin gives up when the hook is absent. An absent producer for an optional call is not fatal (the plugin is expected to guard with bus.hasService(hook) and fall back), but when a producer is present the optional call is a real call-graph edge — it counts toward cycle detection and init ordering exactly like calls. A hook may appear in calls or optionalCalls, never both (boot rejects the contradiction). Use this instead of a bare code comment so the optional dependency is visible at the manifest level (and in the compatibility matrix). Example: @ax/credentials lists storage:get/storage:set/storage:delete-prefix here — its first-boot wipe routine uses them when a storage backend exists and is skipped otherwise.
subscribes — subscriber hooks this plugin listens on. Declared for visibility, but subscribing creates no dependency — a plugin can subscribe to anything without requiring it to exist, so subscribes does not participate in cycle detection.
Plugin config is injected at construction via PluginInitContext.config — there is no configSchema field in the manifest.
Not a package.json field. An earlier convention proposed declaring this manifest as an ax field in each plugin's package.json. It was never adopted; the in-code runtime manifest is canonical. See docs/plans/2026-05-20-manifest-canonical-form-design.md.
Hook bus mechanics
Two primitives, deliberately distinct.
hooks.call(service, ctx, payload) → result — service hooks
- Exactly one registered implementation (boot-time error otherwise).
- Returns the implementation's response.
- Return shape is Zod-validated; mismatches become
PluginError.
- Each service hook has a timeout (configurable per hook). Exceeded =
PluginError.
- If the impl throws, the caller decides how to handle — retry (
llm:call), translate to tool error (tool:execute), propagate (storage:get).
Enforced (2026-05-20): every service call is bounded by a timeout (universal 120s default, overridable). Return-shape validation runs when a hook declares a returns schema. Both are set at registration:
bus.registerService('credentials:get', PLUGIN_NAME, handler, {
returns: z.string(),
timeoutMs: 300_000,
});
See docs/plans/2026-05-20-hook-bus-enforcement-design.md.
hooks.fire(event, ctx, payload) → modified — subscriber hooks
- Many subscribers, called in registration order.
- Each subscriber returns modified payload, passes through unchanged, or calls
reject({ reason }).
- Rejection short-circuits with a structured
PluginError — core lifts to chat_terminated.
- Throws are isolated — caught + logged as
hook_subscriber_failed (plugin name + hook + error) + chat continues. One bad subscriber never tanks the host.
Choosing between them
| Shape | Use service hook | Use subscriber hook |
|---|
| "I'm THE one who does this" | ✓ | |
| "I want to observe / intercept" | | ✓ |
| Exactly one answer | ✓ | |
| Multiple participants | | ✓ |
| Return value is the point | ✓ | |
| Side effects / transforms | | ✓ |
Trying to unify them produces awkward APIs (e.g., picking one subscriber's return value as canonical). Keep them distinct.
Boundary review checklist
Required when adding/changing a service-hook signature, or adding a subscriber hook with a non-trivial payload. Five minutes per review; the cost of a leaked abstraction that grows subscribers is much higher.
1. Alternate impl this hook could have?
Good answer: workspace:apply — git backend today, GCS backend tomorrow. Two concrete impls with known differences.
Bad answer: "I guess we could have... a different version of this?" If you can't name a concrete second impl, the abstraction is probably premature. Just write a function inside one plugin. Promote to a hook when the second impl actually shows up.
2. Payload field names that might leak
Red flags by backend:
| Backend | Leaky names |
|---|
| Git | sha, branch, bundle, ref, commit, tree, parentSha |
| Postgres | generation, oid, nextval, lsn |
| GCS / S3 | bucket, generation, etag, objectName |
| Kubernetes | pod_name, namespace, resourceVersion |
| Filesystem | path (when the concept is a version, not a location) |
| IPC | socket_path, url, port |
Rename to the generic concept: parentSha → parent, sha → version (brand as opaque), bucket → don't expose it at all.
3. Subscriber risk
Could a subscriber parse version as a SHA and call git cat-file? If yes, your "opaque token" isn't opaque in practice. The contract must be: subscribers treat version tokens as opaque — pass them back to service hooks, never parse.
Document it on the type:
type WorkspaceVersion = string & { __brand: 'WorkspaceVersion' };
4. Wire surface
If this hook is also an IPC action (agent → host), the Zod schema lives in your plugin's directory — not a central ipc-schemas.ts. Each plugin owns its slice of the wire surface. Core's IPC dispatcher loads schemas from registered plugins.
The wire surface is intentionally smaller than the in-process hook surface: only "public" service hooks get an IPC action. Internal subscriber hooks (chat:start, llm:pre-call) stay in-process. Agent doesn't inject middleware into host's chat loop.
Common patterns
Opaque version tokens
type WorkspaceVersion = string & { __brand: 'WorkspaceVersion' };
Branded types keep callers from accidentally passing random strings. Implementations mint values however they want (SHA, manifest name, UUID).
Lazy content fetchers in deltas
type WorkspaceDelta = {
before: WorkspaceVersion | null;
after: WorkspaceVersion;
changes: Array<{
path: string;
kind: 'added' | 'modified' | 'deleted';
contentBefore?: () => Promise<Bytes>;
contentAfter?: () => Promise<Bytes>;
}>;
};
Many subscribers only need metadata (what paths changed). A few need bytes (secret scanner, skill indexer). Forcing eager fetches makes everyone pay full cost regardless of who's listening.
Optimistic concurrency via parent
'workspace:apply' (ctx, { changes, parent, reason }) → { version, delta }
Caller passes the version they thought they were changing. Backend CAS-updates against it. Conflict = structured error; caller fetches latest + retries. Git uses update-ref with expected-old-sha; GCS uses ifGenerationMatch. Hook surface doesn't know or care.
Two-phase validation: pre-apply + applied
'workspace:pre-apply' (ctx, { changes, parent, reason }) → modified | reject
'workspace:applied' (ctx, delta)
pre-apply is for veto (secret scanner rejects secrets before storage). applied is for react-after (skill indexer, audit, search). Two hooks because "veto before it lands" is a genuinely different need from "observe what landed."
Config at construction, not at runtime
Plugins receive config when createPlugin(config) runs. No runtime-config hook. Implications:
- Config changes require a restart (hot-reload is explicitly out of scope).
- Each plugin's config schema is local — no central config file that knows about every plugin.
Every plugin's init() runs after all service hooks register
Core guarantees: every registers entry is wired before any init() fires. Plugins can call each other's service hooks from init() safely. No DI container needed — the hook bus IS the inter-plugin API.
Cycle detection is at boot
Core dry-runs declared dependencies from manifests. If A.calls = ['B:foo'] and B.calls = ['A:bar'], boot fails with a clear error naming both plugins. Subscribers don't count — only service calls form cycles.
Tenant-scoped queries via a scope.ts helper
Multi-tenant plugins (@ax/agents, @ax/teams, @ax/auth) own tables prefixed <plugin>_v1_*. Every multi-row read MUST go through a scopedX(db, scope) helper that bakes the WHERE owner_id = … (or membership join) filter in. Stores keep single-row by-id reads but pair them with an inline ACL check.
The local/no-bare-tenant-tables ESLint rule (eslint-rules/no-bare-tenant-tables.js) bans db.selectFrom('<tenant>_v1_*') outside **/store.ts, **/scope.ts, and **/__tests__/. If a query needs to live elsewhere, the right answer is to hoist it into the store, not silence the rule.
Failure modes to design for
| Failure | Mechanism |
|---|
| Subscriber throws | Caught by bus, logged, chat continues. |
| Service impl throws | Propagates as structured PluginError. Caller decides. |
| Service impl wrong return shape | Zod catches, PluginError. |
| Plugin hangs | Per-hook timeout, PluginError. |
| Plugin init fails at boot | Fail fast — refuse to start, name the plugin. |
| Cycle detected | Fail fast — name both plugins. |
| Missing service hook | Fail fast — "plugin X declares calls: ['Y:z'] but no plugin registers it." |
| Two plugins register same service | Fail fast — config must pick one. |
Enforced (2026-05-20): both rows are live — a universal per-hook timeout, and return-shape Zod validation for hooks that declare a returns schema (finding 2).
Pattern: boot-time failures are loud and prevent startup; runtime failures degrade gracefully with structured errors. The hook bus is the enforcement point — every cross-plugin interaction goes through it.