Events
Request Cleanup
The onion's finally blocks ARE the cleanup mechanism:
import { ctxTarget } from 'agent-apps';
async function dbMiddleware(ctx) {
const target = ctxTarget(ctx);
const conn = await db.connect();
target.locals.db = conn;
try { await target.manager.next(); }
finally { conn.close(); }
}
Event Bus
A general-purpose pub/sub system built on globals.internals.events. Any skill can publish events to a named topic. Any skill can subscribe to a topic to receive events.
Event envelope:
interface Event {
id: string; // auto-assigned unique ID (8-char UUID prefix)
topic: string; // which topic this was published to
type: string; // event kind — arbitrary string
source?: string; // who emitted it (tool name)
data?: unknown; // arbitrary payload
timestamp: number; // Date.now() at emission
rootContextId?: string; // root context ID for correlation
}
EventHandler — function form for in-process use, tool-ref form for the public skill API:
type EventHandler = ((event: Event) => unknown) | { ref: string; args?: unknown };
Skills:
| Skill | Description |
|---|---|
event-emit |
Publish an event to a topic. Assigns an auto-generated ID. Pushes to all subscribed queues synchronously, then fires function handlers async. Tool-ref handlers invoke through the pipeline. |
event-subscribe |
Subscribe to a topic. Pass ref to invoke a skill on each event, or omit to create a queue for event-wait. Supports named consumer groups (queue) and replay of buffered events (replay). Idempotent — subscribing to an existing queue is a no-op. |
event-unsubscribe |
Remove handlers for a topic. Pass ref to remove a specific skill handler, or omit to remove all. |
event-wait |
Block until an event arrives on a topic or a timeout expires. Drains available events immediately if the queue is non-empty. Supports named consumer groups (queue) and manual acknowledgment (ack: false). |
event-ack |
Acknowledge or reject events from manual ack mode. Ack removes events from pending. Nack returns them to the front of the queue for reprocessing. |
Event state lives on globals.internals.events (handler map), globals.internals.eventQueues (named queues for event-wait), and globals.internals.eventBuffers (per-topic replay buffers).
Metadata middleware:
event— subscribe skills to event topics via centralized routing config. Takes aroutes:array of{ topic, ref }entries. Also acceptsroute:(singular) — declares the current skill as an event endpoint with ref implicit. When events arrive on those topics, the referenced skill is invoked with the event envelope as args.
metadata:
event:
routes:
- topic: deploy
ref: on-deploy
- topic: rollback
ref: on-rollback
Standard topics:
| Topic | Emitted by | Description |
|---|---|---|
shutdown |
CLI | Fired on SIGINT/SIGTERM. The CLI awaits the event-emit invocation, but individual tool-ref handlers are dispatched asynchronously (fire-and-forget) like any other event. Function handlers are also async. The process exits after event-emit returns, so handlers have limited time to complete. |
gateway-message |
email, slack, websocket, webhook middleware |
Fired when an inbound message arrives on a gateway transport. Consumers can use event-wait with this topic to observe messages without affecting route dispatch. Event source is the gateway name; data includes transport-specific fields (e.g., from, subject, body for email). |
Hooks
The hooks system is a cascade-based extensibility mechanism. Skills declare hook bindings via the $hook annotation in their frontmatter metadata. Hooks wrap pipeline joints ($next and $invoke), forming an onion around each operation. They are the foundation for debug, profile, and observe.
$hook Annotation
Skills declare hook bindings in their frontmatter metadata:
metadata:
$hook: { $self: "$next" } # bind to $next when used as a metadata key
$hook: { $self: ["$next", "$invoke"] } # bind to both trigger points
$hook: { my-tool: "$invoke" } # bind my-tool to $invoke
The $self sentinel means "this tool, when it appears as a metadata key in another skill's cascade." When a skill with $hook: { $self: "$next" } appears as a metadata key, it is separated from the normal middleware chain and instead wraps each chain entry's execution.
Trigger Points
| Trigger | When it fires | Hook receives |
|---|---|---|
$next |
Every ctx.manager.next() call — wraps each chain entry |
The chain entry's args |
$invoke |
Pipeline start — wraps the entire pipeline execution | The tool's args |
How It Works
During cascade resolution, the orchestrator extracts $hook annotations and separates hook entries from the normal middleware chain. At runtime:
$nexthooks wrap each chain entry. Whenctx.manager.next()advances to an entry, the hook is invoked as middleware serving the target context. The hook callstarget.manager.next()to execute the real entry.$invokehooks wrap the entire pipeline. They are invoked before the chain starts, serving the target context.
Debug, Profile, and Observe
Debug, profile, and observe are not part of the default pipeline. They are activated by adding their metadata keys to a skill's cascade:
metadata:
debug: true # activate pipeline debugging
profile: true # activate timing collection
observe: { topic: "my-topic" } # emit invoke events to a topic
Each declares $hook: { $self: ... } in its own frontmatter, so when it appears as a metadata key, it binds as a hook rather than a normal middleware entry.
Debug hooks emit events for pipeline-enter, pipeline-exit, next-enter, next-exit, and breakpoint-hit. Profile hooks collect timing data for every pipeline invocation and chain entry. Observe hooks invoke a callback ref with { phase: "enter" }, { phase: "exit" }, and { phase: "error" } for each skill invocation, passing the tool name, args, result, and context ID.
Because hooks are resolved from the cascade, they propagate through inheritance — adding debug: true to a main skill instruments the entire project. Setting nonlocals.debug = false on a child context suppresses instrumentation for that subtree.
Hooks are null by default (zero overhead when unused) and are per-context, not global — instrumenting one pipeline does not affect others.