Tool Interface
Every tool is a function:
(ctx: Context, args?: unknown) => unknown
ctx is always the first argument. args is whatever the caller passed — the orchestrator calls fn(ctx, args) with no mapping or normalization.
- Returning non-
undefinedsetsctx.locals.resultand stops the downstream chain. - Returning
undefinedcontinues the chain. - Throwing propagates up through the onion.
Metadata
A tool definition consists of:
fn— The tool function(ctx, args?) => unknown.frontmatter— The parsed metadata fields below (typeToolFrontmatter).src— The source URI (file://,mcp://, ornull).module— Cached module reference for code tools (avoids re-importing).content— Post-frontmatter body for markdown tools.
| Field | Description |
|---|---|
name |
Unique name (required). Must follow tool name rules. |
role |
Tool role: main (project entry point/authority root), middleware (context configurator), or context (prompt content injected into agent tool docs). Absent by default, except folder skills (SKILL.md) which default to context. |
description |
One-sentence explanation. |
params |
JSON Schema for params. When set, defines the argument shape for the tool. Tools without params accept no arguments. Property-level description fields are the primary place for agent-facing usage details — the compiler reads them to understand tool behavior. Supports $resolve for dynamic resolution (see below). |
returns |
JSON Schema for the return value. When set, the agent is forced to return structured data via tool call instead of prose. Property-level description fields document the expected output shape. Supports $resolve for dynamic resolution (see below). |
visibility |
Controls listing and agent visibility. Value: hidden. See Visibility section. |
allowed-tools |
Space-delimited string of glob patterns restricting which tools the agent may call. Supports specifier grammar: tool-name(key=value,$deny). YAML arrays are accepted and joined automatically. |
tags |
User-defined labels for categorization and querying. Array of strings, normalized to lowercase-with-hyphens at registration time. |
metadata |
Cascade configuration. Plain keys trigger metadata middleware. $-prefixed keys are cascade annotations ($inherit, $private, $public, $local, $global, $dynamic, $static, $merge, $order, $remove, $self, $hook). |
Argument Passing
Public tools (those with a JSON Schema) always receive an object as args, because tool params use type: object and agent tool calls produce objects. These tools destructure freely:
export default async function(ctx, { name, title }) { ... }
Internal tools (middleware, metadata/directive middleware) receive args from YAML config or the pipeline. The shape depends on the source. ctx.args is set once at invocation time and never overwritten. Middleware receives its own configuration as args, not via ctx.args.
Tool Formats
Folder Skill
A directory containing SKILL.md:
skills/api-create/
SKILL.md
The markdown file's frontmatter is authoritative for all metadata. If the skill needs code execution, use metadata.delegate to point to a code tool.
Single-File Skill
A .skill.md file with YAML frontmatter and a markdown body.
Code Skill
A .skill.js or .skill.ts file. Exports a default async function. Optionally exports frontmatter for metadata:
export const frontmatter = {
name: 'validate',
description: 'Validate a note',
metadata: { params: { type: 'object', properties: { title: { type: 'string' } } } }
};
export default async function(ctx, { title }) {
if (!title) return { error: 'INVALID', message: 'Title required' };
return { valid: true };
}
Frontmatter
YAML delimited by --- (markdown) or exported frontmatter object (code). All extension points go through the metadata key. Delegate is metadata.delegate. Only name, description, allowed-tools, and metadata are top-level frontmatter fields. All other fields — role, params, returns, visibility, tags — live inside metadata and are read by extractFrontmatter at load time.
Cascade annotations use the $ sigil: $inherit, $private, $public, $local, $global, $dynamic, $static, $merge, $order, $remove, $self, $hook. The $ prefix ensures they cannot clash with tool names or data keys. Annotations are always top-level children of metadata: — values are always plain YAML with no $-keys at any nesting depth.
Inline Delegates
A single-file .skill.md can be fully self-contained by inlining its code using an inline:// ref:
---
name: greet
metadata:
delegate: "inline://code,export default async function(ctx, { name }) { return `Hello, \\${name}!`; }"
---
You are a greeting tool. Greet the user by name.
The inline:// scheme format is inline://<tag>,<payload> where tag is code, markdown, or base64. Escape \${ in YAML to prevent metadata interpolation.
inline://code,... — JavaScript ESM module source. Must export a default async function. The delegate middleware passes the ref to findTool, which resolves it via data:text/javascript;base64,... import. The result is cached as a programmatic tool keyed by the full ref string. No temp files are written.
inline://markdown,... — Markdown with YAML frontmatter, parsed like a .skill.md file. Newlines are literal \n in the YAML string:
metadata:
delegate: "inline://markdown,---\nname: x\n---\nDo the thing."
inline://base64,... — Base64-encoded payload that decodes to another inline:// ref (recursive — the decoded string is re-parsed). Use when the payload contains characters that fight with YAML quoting:
metadata:
delegate: "inline://base64,aW5saW5lOi8vY29kZSxleHBvcnQgZGVmYXVsdCBhc3luYyBmdW5jdGlvbihjdHgsIHsgbmFtZSB9KSB7IHJldHVybiBgSGVsbG8sICR7bmFtZX0hYDsgfQ=="
For longer code, point the delegate at a separate code file (delegate: ./lib/handler.js).
Dynamic Schema Resolution ($resolve)
JSON Schema objects for params and returns support a $resolve path expression for dynamic resolution at describe/discover time. When $resolve is present and the path resolves to a non-null object on the context, the resolved object replaces the static schema. Otherwise the static schema is used as-is.
metadata:
params:
$resolve: nonlocals.gateway.sendSchema
type: object
properties:
message: { type: string }
At runtime, resolveJsonSchema(schema, ctx) checks for $resolve, calls pathGet(ctx, path), and returns the resolved object if found. The params and returns middleware both call this before setting ctx.run.tool.params and ctx.run.tool.returns. Agent tool discovery (agent-discover) and skill-describe also resolve $resolve for both fields.
The $resolve keyword is registered with Ajv as a custom keyword so it does not cause validation errors when present in a schema.
This enables transport-specific schema specialization — for example, gateway-send and gateway-receive declare $resolve paths that point to params stamped on the gateway object by the active transport provider (Slack, email, web). The agent sees the transport-specific schema without special-case code.