Tool Resolution

Tools are NOT pre-discovered into a global registry. Instead, the runtime resolves tools lazily on demand using findTool() and listTools().

findTool(ref, globals)

Resolves a tool reference in three forms:

  1. URI (file://, mcp://, inline://) → searches programmatic tools by src field. For file:// URIs, converts to a filesystem path and loads directly via loadSkillsFromFile(). Supports #fragment for multi-skill files.
  2. Path (any string that is not a valid bare name — contains /, \, uppercase, underscores, or exceeds 64 characters) → resolves to an absolute path and loads directly via loadSkillsFromFile(). Bypasses the search path entirely, like /usr/bin/foo bypasses $PATH in bash. Supports #fragment for multi-skill files.
  3. Bare name → checks globals.tools.programmaticTools first, then searches paths left-to-right on disk. For each directory, checks the build-time cache (.agent-apps.json) first, then builds a full name index (cached per directory) by loading all skill files and mapping meta.nameToolDef. On index miss, rebuilds from disk in case the index was stale. First path entry with a match wins.

Results are cached on globals.cache.tool.

listTools(globals)

Enumerates all tools visible from globals.config.paths. Programmatic tools first, then disk tools from each path entry. Used by agent tool selection and skill-list. Results are cached on globals.cache.list.

Cache

Caches live for the process lifetime. Explicit invalidation only — clearToolCache(name) after compile, clearAllCaches() after clean. No TTL complexity.

The Cache interface holds these maps:

Cache Key Value Purpose
tool name\0pathsKey ToolDef | null findTool results. Null means "not found."
list pathsKey ToolDef[] listTools results.
dir dir\0ignoreKey string[] listDir results (file paths in a directory). Keyed by directory path and ignore patterns.
cascade name\0pathsKey { middleware, order, hook, toolKeys, selfDefaults?, resolvedChain? } Cascade results per tool.
file absolute path ToolDef[] Parsed tool definitions from a file. Multi-skill files return multiple entries. Avoids re-reading the same file.
authority src | name ToolDef Authority resolution results. Avoids redundant directory walks.
cyclicPeers (singleton) Map<name, Set<name>> | null SCC peer sets for auto-static cycle detection.
cyclicPeersSeededDirs (singleton) Set<string> | null Directories whose cyclicPeers data was seeded from .agent-apps.json.
mainInDir absolute dir ToolDef | null Main skill per directory. Avoids redundant readdir+stat.
nameIndex dir Map<name, filePath> Per-directory name→file path index for O(1) bare-name lookup.
promotions (singleton) { local, global } | null Resolved $local/$global promotions from the main skill's metadata. Computed once, invalidated by clearAllCaches.
cascadeDeps name\0pathsKey string[] Volatile interpolation dependency paths per tool. Paths that change between invocations (e.g., args.*) are tracked so the cascade cache key includes their values.

Programmatic Tools

globals.tools.programmaticTools holds tools registered by code rather than discovered from disk: bootstrap, MCP tools, inline delegates, callback refs, and test mocks. findTool checks programmatic tools before disk search, so they shadow disk tools. Callback refs (created via createCallbackRef) are programmatic tools with generated names ({label}-{8-char-uuid}) and are subject to allowed-tools enforcement like any other tool.

For authority resolution, programmatic tools have src: null or synthetic URIs (mcp://, inline://). No filesystem walk is possible, so findAuthority returns the project's main skill or the library's main skill.

Utility Helpers

The runtime exports helper functions that eliminate common multi-field fallback patterns. All are importable from agent-apps.

Helper Signature Returns
ctxTarget(ctx) (ctx: Context) => Context ctx.envelope.target, throwing if target === ctx (not middleware). Used by all middleware to access the served context.
ctxPaths(ctx) (ctx: Context) => PathEntry[] ctx.locals.config.paths ?? ctx.globals.config.paths
ctxCwd(ctx) (ctx: Context) => string ctx.locals.config.cwd ?? ctx.globals.config.cwd ?? process.cwd()
ctxHookTrigger(ctx) (ctx: Context) => string | undefined Read the hook trigger from the current chain entry ("$next", "$invoke", or undefined).
ctxHookTarget(ctx) (ctx: Context) => string | undefined Find the next non-hook entry name in the chain (the real entry a $next hook wraps).
pathEntryDir(entry) (entry: PathEntry) => string Resolve the raw directory string from a path entry. Throws on { ignore } entries — use iterPaths() instead.
iterPaths(paths) (paths: PathEntry[]) => Generator<DirEntry> Iterate directory entries from a paths array, skipping { ignore } entries. Yields { dir, external }.
extractIgnore(paths) (paths: PathEntry[]) => string[] Extract ignore patterns from a paths array. Returns the pattern strings from { ignore } entries.
mapPaths(paths, fn) (paths: PathEntry[], fn: (dir, external, entry) => PathEntry[]) => PathEntry[] FlatMap over directory entries, preserving { ignore } entries. Callback returns [] to remove, [entry] to keep, [a, b] to expand.
filterPaths(paths, fn) (paths: PathEntry[], fn: (dir, external) => boolean) => PathEntry[] Filter directory entries from a paths array, preserving { ignore } entries.
registerTool(globals, opts) (globals: Globals, opts: RegisterToolOptions) => void Registers a programmatic tool on globals.tools.programmaticTools
unregisterTool(globals, name) (globals, name) => void Removes a programmatic tool and clears its cache entry
matchPattern(pattern, name, tags?) (string, string, string[]?) => boolean Glob match with #tag selector support. See Pattern Syntaxes.
pathGet(obj, expr) (unknown, string) => unknown Read a value by path expression. See Pattern Syntaxes.
pathSet(obj, expr, value) (unknown, string, unknown) => void Write a value by path expression, creating intermediates.
pathQuery(obj, expr) (unknown, string) => unknown[] All matches for a path expression.
pathRemove(obj, expr) (unknown, string) => void Delete matched paths.
pathKeys(obj, exprs) (Record, string[]) => Set<string> Resolve expressions to top-level key names.
isMetaKey(key) (string) => key is MetaKey True if key starts with $. See Pattern Syntaxes.
safeStringify(value, indent?) (unknown, number?) => string JSON.stringify with safe handling of functions, promises, and circular references.
serialize(value, opts?) (unknown, SerializeOptions?) => unknown Transform any value into a JSON-safe structure. Handles cycles, functions, Promises, Maps, depth, size. Returns a plain value — use stringify() for a JSON string.
deserialize(value, opts?) (unknown, DeserializeOptions?) => unknown Transform a JSON-safe structure back to a rich value. Reconstructs $fn-encoded functions when opts.functions is true.
stringify(value, opts?) (unknown, SerializeOptions?) => string Serialize to a JSON string. Drop-in replacement for JSON.stringify. Safe by default.
parse(text, opts?) (string, DeserializeOptions?) => unknown Parse a JSON string and optionally deserialize $fn conventions. Drop-in replacement for JSON.parse.
safeClone(value, opts?) (unknown, SafeCloneOptions?) => unknown Deep-clone any value into a plain, JSON-safe structure. Handles circular references, functions, Promises, Maps. Depth-limited and optionally size-limited.
safeSnapshot(value, maxDepth?, maxBytes?) (unknown, number?, number?) => unknown Size-limited deep clone for diagnostic/debug event data. Defaults: depth=4, maxBytes=10KB.
emptyGlobals() () => Globals Create a fresh Globals object with empty caches and default config. Useful for testing.
transferableSerializerSource(names?) (names?) => string Self-contained JS source for $fn-aware stringify, parse, and reviveFns. Designed for injection into environments that cannot import this module (e.g., isolated-vm sandboxes).
serializeContext(ctx, maxDepth?) (Context, number?) => Record | null Serialize a context for logging or debugging. Replaces functions with undefined, circular refs with "[Circular]", depth-limited.
resolveJsonSchema(schema, ctx) (JsonObject | undefined, Context) => JsonObject | undefined Resolve a JSON Schema with optional $resolve path expression. If $resolve is present and the path resolves to a non-null object on ctx, returns the resolved object. Otherwise returns the static schema unchanged.

registerTool accepts a single RegisterToolOptions object with name, fn, and optional fields: description, params, returns, tags, visibility, metadata, role, allowedTools, src, module, content. unregisterTool also calls clearToolCache to ensure subsequent lookups don't return stale results.

Glob Matching

See Pattern Syntaxes for the full syntax reference. Summary: real globs via picomatch with #tag selectors and (specifier) grammar.

# Allow all internal tools, plus a specific custom tool
allowed-tools: "#meta-internal custom-tool"

# Allow everything except destructive tools
allowed-tools: "* #destructive($deny)"
# Deny specific operations with brace expansion
allowed-tools: "* context-manager(op={finish,fail},$deny)"

# Constrain subagent tool access via arg constraint
allowed-tools: "file-read agent(allowedTools=file-read)"
# Allow callback refs (programmatic tools with generated names)
allowed-tools: "file-read callback-*"

# Trust: auto-approve all internal tools, prompt for others
trust:
  rules: "#meta-internal($policy=allow) *"

The $ prefix also escapes into the internal tool namespace. The context-manager skill's operations are always allowed unless explicitly denied via arg constraints. For example, context-manager(op=finish,$deny) denies the finish operation. Normal tools follow the opposite rule: if allowed-tools is set, only explicitly listed tools are allowed.

Visibility

Visibility is controlled by metadata.visibility and determines how a tool appears in listings and to agents.

metadata.visibility In skill-list (default) Available to agents Callable by code
(absent) Yes Yes Yes
hidden No No Yes

By default, skill-list returns tools that are not hidden. With includeHidden: true, it returns all tools including hidden ones. Tools without params accept no arguments but are still visible — only visibility: hidden controls listing.

Tags

Tools can declare user-defined tags for categorization and querying:

metadata:
  tags: [api, destructive, slow]

Tags live inside metadata (alongside role, params, etc.). In code tools, they go in the metadata object of the exported frontmatter. Tags are normalized when loaded: lowercased, non-alphanumeric characters replaced with hyphens, consecutive hyphens collapsed, leading/trailing hyphens stripped, duplicates removed.

The skill-list tool accepts a tags argument (array of strings, AND logic) to filter results. The skill-describe tool includes tags in its output.

Tags have no effect on tool behavior — they are purely for organization and querying.

Reserved Tags

All library skills use meta- prefixed tags. These are reserved — user skills should not use the meta- prefix.

Ownership:

Tag Meaning
meta-internal Shipped with the framework in skills/. Every library skill has this tag.

Role (how the skill integrates with the framework):

Tag Meaning
meta-middleware Participates in the middleware pipeline via ctx.manager.next().
meta-metadata Implements a frontmatter metadata: key.
meta-directive-inline Implements an inline remark directive (:name[arg]).
meta-directive-block Implements a block remark directive (:::name{attrs}).

Usage context (where/how the skill is invoked):

Tag Meaning
meta-runnable Can be run as a standalone command.
meta-action Generic toolbar action.
meta-action-text Toolbar action for markdown skills.
meta-action-code Toolbar action for code skills.

Tool References

Anywhere the spec says "tool reference" — $inherit, metadata.delegate, ctx.manager.invoke(), CLI arguments, :skill[], :tool[] directives — the string is resolved through findTool():

  1. URI (file://, mcp://, inline://) → matched by src in programmatic tools. For file:// URIs, converted to a filesystem path and loaded directly. inline:// is used by the delegate middleware for inline code delegates registered as programmatic tools.
  2. Path (any string that is not a valid bare name — contains /, \, uppercase, underscores, or exceeds 64 characters) → resolved to an absolute path, loaded directly. Bypasses the search path entirely.
  3. Bare name (1–64 lowercase alphanumeric characters and hyphens, no leading/trailing/consecutive hyphens) → searched by name across paths. Programmatic tools first, then disk.

Ask AI