The Orchestration Model
Invocation
When ctx.manager.invoke(ref, args) is called:
$NAMEextraction. Any$-prefixed key inargs(e.g.,$context) is extracted and mapped to the corresponding invoke option (e.g.,options.context). The$prefix is stripped. Explicit options win over$NAME—$NAMEis a serializable alias, not an override. When both the explicit option and the$NAMEvalue are objects, they are merged:$NAMEfills in missing fields without overwriting existing ones. This lets$contextaddnonlocals/localsalongside an invoker-setparent. The cleaned args (without$-keys) become the effective args.- Resolve
refviafindTool()using the current paths. Throw if not found. - Create a child context. Set
targetfrom options (for chain entries) or default to self. Apply context seed: any property inoptions.context(exceptenvelope,parent,target, andsignal, handled at creation time) is overlaid onto the new context. - Set
ctx.run.origin.uri,ctx.locals.prompt,ctx.run.origin.frontmatter, andctx.run.tool.*from the tool definition.ctx.argswas already set at context creation time. - Push a history frame
{ tool: name, args, timestamp }. - Find the tool's authority via
findAuthority(). - Run the cascade: per-level interpolation, extract annotations, merge with
$mergepolicies, apply$remove, computetoolKeysfrom$dynamic/$static. - Resolve the middleware chain: for each key in
toolKeysthat matches a tool, build an entry with$orderconstraints, topo-sort, run viactx.manager.next().
The full pipeline runs for every invocation. There are no shortcuts.
The Middleware Chain
The chain is a mutable sorted array of tool references. Each entry is a name, not a pre-resolved function. When ctx.manager.next() reaches an entry, it invokes the tool through the orchestrator — full pipeline, own context, with target set to the caller's context. This means every middleware entry gets its own middleware chain, and markdown tools used as middleware get their directives expanded and agent run.
Entries with an explicit fn (injected continuations, programmatic entries) are called directly without going through the orchestrator.
Sorting uses topological order based on before/after constraints, with insertion order as tiebreaker (stable sort). Phase sentinels (strings starting with $) act as virtual anchors — they constrain ordering but are excluded from the output.
Extra middleware (from the context seed, $context.middleware in args, or dynamic injection) supports three modes: override (same name as a cascade entry replaces it, inheriting the original's ordering constraints as fallback), remove (remove: true suppresses the matching cascade entry), and append (new name adds to the chain). Entries without fn are serializable — agents and MCP callers can pass them via $context in args.
Each ctx.manager.next() is a real await. The call stack IS the onion. Returns propagate. Exceptions propagate. try/catch/finally works naturally.
Rules:
- Injected middleware can only run downstream.
next()called after the chain is finished returnsctx.locals.resultimmediately.- Empty chain
next()resolves immediately. - Cycles in constraints throw a configuration error.
- Cycles in the invocation stack (tool A's pipeline invokes tool B whose pipeline invokes tool A) are silently skipped: the chain entry is not executed, and a warning is logged. Cycle detection only applies to chain entries (middleware in a pipeline). Independent
ctx.manager.invoke()calls start a separate pipeline and cannot trigger false cycle detection — the same middleware (e.g.,agent-execute) can appear in multiple independent pipelines without conflict.
Execution Flow
- Descend: Middleware runs "before" logic, calls
await ctx.manager.next(). - Recurse:
next()pops and calls the next middleware. - Bottom: A middleware returns a value, calls
finish(), or doesn't callnext(). - Unwind: Control returns up through "after" logic.
finallyblocks run.
Return Values
Returning non-undefined sets ctx.locals.result and marks the chain as finished. Returning undefined does NOT clobber an existing result. ctx.manager.next() resolves to ctx.locals.result, so middleware can inspect/transform on the way back up.
Error Handling
Error handling uses the Koa-style onion pattern — middleware with try/catch around target.manager.next(). There is no separate error phase. Error-handling middleware positions itself before execute to wrap the execution phase:
metadata:
$order:
my-error-handler: { after: [$pre-execute], before: [execute] }
my-error-handler:
import { ctxTarget } from 'agent-apps';
export default async function(ctx) {
const target = ctxTarget(ctx);
try {
await target.manager.next();
} catch (err) {
// Inspect err, attempt recovery, set target.locals.result to suppress
target.locals.result = 'recovered';
}
}
Multiple error handlers compose naturally via the onion. Inner handlers catch first; if they rethrow, outer handlers get the error. Each can attempt a different recovery strategy.
Middleware
Middleware is tools participating in the pipeline — gathered from multiple sources and merged into a single ordered chain.
Sources
The middleware chain for each invocation is built from metadata keys. The orchestrator resolves the tool's metadata through the cascade (authority → inherit chain with public/private visibility → tool's own keys → CLI overrides), interpolates strings, then converts each metadata key into a middleware entry:
- For each key in the merged metadata, check if a tool with that exact name exists via
findTool(). If not, skip silently. There is no prefix expansion — metadata keys map to tool names directly. - The metadata value becomes the middleware's
args(after$-sigil annotation extraction for ordering).
Additional middleware sources:
- Explicit middleware. Passed via the context seed on
ctx.manager.invoke()(e.g.,context: { locals: { middleware: { ... } } }). - Directive middleware. Injected by
directivesbased on remark directives. Directive:nametriggers toolname.
Naming Convention
Pipeline middleware uses descriptive names: execute, validate-args, validate-returns, validate-allowed-tools, agent-execute, directives. Metadata middleware uses names matching their metadata key: cwd, paths, params, delegate, model, trust, log.
Entry Normalization
Middleware entries are { name, args, before?, after?, remove? }. The resolver builds entries from the cascade's merged metadata: each key that matches a tool becomes an entry with the metadata value as args. Ordering comes from the $order annotation, not from per-value annotations. Entries without $order constraints get the default after: [$configure], before: [$post-configure].
Dynamic Injection
When metadata or directive middleware injects new entries:
- New entries are inserted immediately after the current chain position.
- Constraints referencing already-executed middleware are silently stripped.
- Entries with explicit
before/afterconstraints trigger a re-sort of the remaining chain. - Entries without constraints are inserted in the order provided.
Directive Ordering
Directive middleware always runs after all metadata middleware. Within directives, execution follows document order.
Leaf Middleware and Cycle Prevention
When a middleware tool is invoked as a chain entry, it gets its own full pipeline — the orchestrator resolves its authority, walks the inherit chain, and builds a middleware chain from the merged metadata. For middleware that appears in the default pipeline, this creates a cycle: the middleware's pipeline includes itself.
The runtime prevents this automatically via auto-static cycle detection. At cascade time, the orchestrator computes the strongly connected components (SCCs) of the tool dependency graph using Tarjan's algorithm. Any toolKey whose target is in the same SCC as the current tool is demoted to data (not invoked as middleware). Self-references are always removed. The result is cached and invalidated when tool caches are cleared.
As a second safety net, the runtime detects cycles at invocation time by walking the parent context chain. If a tool appears in its own pipeline ancestry, the invocation is silently skipped and a warning is logged.
This means middleware authors do not need to declare $inherit: false or $static to prevent cycles — the runtime handles it. These annotations are still supported as explicit overrides for tools that want to opt out of inheritance or control which keys are tool invocations.
// No $inherit: false or $static needed — auto-static handles cycles
export const frontmatter = {
name: "role",
metadata: {
role: "middleware",
visibility: "hidden",
tags: ["meta-internal", "meta-metadata"],
params: { type: "string" },
},
};