Scripted Tools (TypeScript)¶
A scripted tool is a custom Trailblaze tool authored in TypeScript that drops into a
trailmap (a directory of Trailblaze configuration that groups a target app with the
tools that drive it) with no Kotlin code, no Gradle build, and no per-tool YAML
descriptor. You declare the tool’s inputs (and optional structured result) as TypeScript
interfaces, write the handler against the typed ctx.tools.<name>(args) composition
surface, and the framework takes care of the rest — schema extraction, IDE typings, MCP
registration, dispatch.
This page is the per-tool reference. If you’re starting from zero, read Your First Trailmap first — it walks an empty directory all the way to a passing run, and that walkthrough is the natural lead-in to the material on this page. Come back here for the per-tool authoring details once your workspace is running.
Vocabulary cheat sheet¶
You’ll see these terms throughout the page; here’s the cheat sheet so you can read straight through without context-switching:
| Term | What it means |
|---|---|
| target | An app under test (iOS Contacts, your team’s web checkout flow). |
| trailmap | A directory that groups one target with the tools, system prompt, and trails that drive it (trails/config/trailmaps/<id>/). |
| trail | A .yaml test file (blaze.yaml + optional <device>.trail.yaml recording) that exercises a target. |
trailblaze CLI |
The single binary you run. On first invocation it boots a local daemon that holds the device session and dispatches tools. Restart the daemon when you change a trailmap; everything else (typings, schema extraction, test runs) flows through the CLI. |
| driver | The framework’s adapter for one platform — ios-host for iOS Simulator, playwright-native for browsers, etc. A target picks which drivers it supports under platforms.<p>.drivers:. |
| host vs on-device | “Host” is the developer machine running the daemon and CLI. “On-device” is the emulator / simulator / browser the trailmap drives. The requiresHost: true spec flag keeps an in-process tool host-only (skips on-device registration); it does not change the runtime. |
| QuickJS | The embedded JavaScript engine the daemon uses to dispatch .ts tools in-process (no subprocess fork, no Node node_modules). Tools needing full Node-compatible APIs select the Bun subprocess via runtime: subprocess in their descriptor. |
| MCP | Model Context Protocol — the wire protocol the daemon uses to advertise tools to the agent. Scripted tools register through this automatically; you never touch the protocol directly. |
See Getting Started for install + first-device pairing.
The worked references throughout this page are the two example trailmaps in the OSS tree:
examples/ios-contacts/— drives Apple’s built-in iOS Contacts app through 9 scripted tools (search, open, create, delete, verify, plus a composition example). SeeREADMEfor the workspace-level walkthrough.examples/wikipedia/— drives liveen.wikipedia.orgthrough Playwright Native with 9 scripted tools (search, articles, language switching, banner dismissal, structure verification, plus a composition example). SeeREADMEfor the workspace-level walkthrough.
Both trailmaps ship the canonical shape this page documents: one .ts per tool, no
sibling YAML, typed inputs, TSDoc-as-description, composition via ctx.tools.
The shape¶
A scripted tool is a single .ts file containing a trailblaze.tool<I, O>(spec, handler)
export. The export name IS the tool name the LLM will see. Everything the framework needs
— the tool’s input schema, its result shape, its description, its platform/driver gates —
is derived from the .ts file itself: the type parameters, the spec object, and the TSDoc
above the binding.
The example below imports a few helpers (SELECTORS, articleUrl, nonEmptyString)
from ./wikipedia_shared — that’s a sibling module you write, not a framework
import. Every trailmap that has more than one tool ends up wanting a *_shared.ts
helper to keep selectors and constants out of individual tool bodies; see
Shared helpers below for what goes in it. The framework treats it as
a plain TypeScript module — it has no trailblaze.tool(...) export, so it doesn’t
register as a tool.
// examples/wikipedia/trails/config/trailmaps/wikipedia/tools/wikipedia_web_openArticle.ts
import { trailblaze } from "@trailblaze/scripting";
import { SELECTORS, articleUrl, nonEmptyString } from "./wikipedia_shared"; // ← author-written helpers
export interface OpenArticleArgs {
/** Title of the Wikipedia article to open. Defaults to "Wikipedia". */
title?: string;
}
/**
* Open a Wikipedia article by title. Use this whenever the task is to
* navigate to a specific article — e.g. "open the Albert Einstein article",
* "go to the Python article". Asserts the destination's #firstHeading is
* visible.
*/
export const wikipedia_web_openArticle = trailblaze.tool<OpenArticleArgs>(
{ supportedPlatforms: ["web"], requiresContext: true },
async (input, ctx) => {
const title = nonEmptyString(input.title, "Wikipedia");
await ctx.tools.web_navigate({ action: "GOTO", url: articleUrl(title) });
await ctx.tools.web_verifyElementVisible({ ref: SELECTORS.firstHeading });
return `Opened "${title}".`;
},
);
That’s the whole tool. There is no wikipedia_web_openArticle.yaml, no inputSchema:
block to maintain in two places, no name: to keep in sync. The export name is
wikipedia_web_openArticle; the framework registers a tool by exactly that name.
Read the full file:
wikipedia_web_openArticle.ts.
What the framework derives from this one file¶
| From | Becomes |
|---|---|
The export name (wikipedia_web_openArticle) |
The tool’s dispatchable identifier; an entry on ctx.tools for sibling tools to compose; the name listed under target.tools: in trailmap.yaml. |
The TSDoc above export const |
The tool’s LLM-facing description (and IDE hover text on ctx.tools.wikipedia_web_openArticle(...)). |
The <I, O> type parameters |
The tool’s input JSON Schema and (when O is given) typed result shape. Per-field TSDoc on each interface property becomes that field’s JSON Schema description. |
The spec object ({ supportedPlatforms, requiresContext, ... }) |
Registration gates and metadata hints carried into the runtime tool descriptor. |
| The handler body | The runtime behavior. |
Three overloads, simplest-to-richest¶
The most common form is #1 (typed input, string return) — that’s what every UI-driving tool in the worked examples uses. The others trade pieces away or add structured returns when the tool’s contract calls for them.
// 1. Typed input, string return — the workhorse for "drive UI, report what happened".
// This is what 90%+ of scripted tools end up looking like.
export const search = trailblaze.tool<SearchArgs>(
{ supportedPlatforms: ["web"], requiresContext: true },
async (input, ctx) => { /* ... */ return `Searched for "${input.query}".`; },
);
// 2. Typed input AND typed result — when the tool returns structured data callers
// consume programmatically (rare; usually you want a human-readable string).
export const getAppMetadata = trailblaze.tool<EmptyInput, AppMetadata>(
async (_, ctx) => ({ version: "1.0.3", buildNumber: 142 }),
);
// 3. No input, string return — the trivial smoke-test form. Defaults to
// TInput = {}, TResult = string.
export const ping = trailblaze.tool(async () => "pong");
The spec object is also optional — pass it as the first positional argument when the tool
needs gating (supportedPlatforms, requiresContext, etc.); omit it for a bare-handler
shape:
// Bare-handler — no spec.
trailblaze.tool<Args>(async (input, ctx) => { ... });
// With spec.
trailblaze.tool<Args>(
{ supportedPlatforms: ["ios"], requiresContext: true },
async (input, ctx) => { ... },
);
The analyzer requires named type references for the <I, O> slots — inline
primitives like <{q: string}, string> are rejected. Declare an interface (or import
EmptyInput for the no-input case) and pass its name.
Wiring a tool into a trailmap¶
A trailmap is the unit that groups a target with the tools that drive it. Layout:
my-workspace/trails/config/
├── trailblaze.yaml # workspace anchor
└── trailmaps/
└── wikipedia/
├── trailmap.yaml # target manifest (display_name, platforms, tools list)
├── wikipedia-system-prompt.md # optional LLM tool-selection guidance
└── tools/
├── wikipedia_shared.ts # shared helpers (selectors, label constants)
├── wikipedia_web_openArticle.ts # one tool per file
├── wikipedia_web_searchAndOpenFirstResult.ts
└── ...
The trailmap manifest lists each tool by bare export name under target.tools::
# examples/wikipedia/trails/config/trailmaps/wikipedia/trailmap.yaml
id: wikipedia
target:
display_name: Wikipedia (en)
system_prompt_file: wikipedia-system-prompt.md
tools:
- wikipedia_web_openMainPage
- wikipedia_web_openArticle
- wikipedia_web_searchAndOpenFirstResult
- wikipedia_web_verifyArticleStructure
# … more …
platforms:
web:
drivers: [playwright-native, playwright-electron]
tool_sets:
- web_core
- web_verification
- memory
The trailmap loader walks tools/ for every .ts that exports a trailblaze.tool(...)
declaration; the names listed under target.tools: decide which of those tools are
advertised to the agent for this target. Files in tools/ that are imported as
helpers (wikipedia_shared.ts) but don’t export a tool are ignored by the loader. Tools
that DO export but aren’t listed under target.tools: (e.g. typed-demo or
work-in-progress drafts) live in the candidate pool but stay invisible to the agent
until you add their name to the list.
When a trailmap ships both iOS and Android tools (or otherwise has tools that only
make sense for one platform), declare each platform-specific tool under
platforms.<p>.tools: and keep genuinely cross-platform tools at the top level under
target.tools:. See
Trailmaps → Per-platform scripted tools
for the example shape and the single-platform validation rule.
See Trailmaps for the full manifest schema (dependencies, defaults, toolsets, waypoints).
Typed inputs¶
The input interface is the source of truth for the parameter schema. Per-field TSDoc
becomes per-field JSON Schema description. The analyzer also picks up JSDoc tags like
@default and turns them into schema defaults — the runtime fills missing fields from
those defaults before the handler sees input:
export interface SearchAndOpenFirstResultArgs {
/** Query to type into the search box. */
query?: string;
/** Heading text to assert on the opened article. Defaults to `query`. */
expectedHeading?: string;
/** Submit the search form (default true). */
openFirstResult?: boolean;
}
The runtime validates args against the derived schema before the handler runs. If the
agent sends a malformed payload (wrong type, missing required field), the dispatch fails
fast with a ValidationError envelope that names the offending fields — the LLM can
self-correct on the next round without crashing inside the handler.
For typed structured returns, declare a second type parameter. Both type parameters
must be named type references — the analyzer rejects inline type literals (e.g.
<{title: string}, ArticleMetadata>) in the <I, O> slots, so declare an interface
for the input even when it has one field:
export interface DescribeArticleArgs {
title: string;
}
export interface ArticleMetadata {
title: string;
lengthBytes: number;
}
export const wikipedia_web_describeArticle = trailblaze.tool<DescribeArticleArgs, ArticleMetadata>(
{ supportedPlatforms: ["web"], requiresContext: true },
async (input, ctx) => {
// ... read the page ...
return { title: input.title, lengthBytes: 12345 };
},
);
Arrays as interface fields flow through the analyzer normally
(filters: string[]). Arrays as the top-level type parameter need a named alias
(type ItemList = Item[]) — the analyzer requires a named root type for the <I, O>
slots.
For the no-input case, import EmptyInput:
import { trailblaze, type EmptyInput } from "@trailblaze/scripting";
export const wikipedia_web_openMainPage = trailblaze.tool<EmptyInput>(
{ supportedPlatforms: ["web"], requiresContext: true },
async (_, ctx) => { /* ... */ return "Opened main page."; },
);
The spec object¶
The spec carries the framework hints that used to live under _meta: { trailblaze/... }
in the legacy YAML descriptor. Every field is optional:
| Field | Purpose |
|---|---|
description?: string |
Optional LLM-facing description. When set, takes precedence over the TSDoc above the export const (and a YAML sidecar’s description:, if any, still takes precedence over both — see Where the description comes from). Use when you want a tighter agent-facing string than the TSDoc and don’t want to maintain a sidecar YAML. |
supportedPlatforms?: ("web" \| "android" \| "ios" \| "desktop")[] |
Registration gate. Tool is only registered for sessions whose platform matches. Empty/omitted = all platforms. |
requiresContext?: boolean |
UX hint surfaced in tool catalogs — “this tool needs a live device session to be useful” (e.g. it dispatches UI tools that won’t work without a connected emulator / browser). Not a registration filter; informational only. |
requiresHost?: boolean |
On-device visibility gate — skip registering this (in-process) tool on-device. NOT a runtime selector: it does not give the tool Node APIs. A tool that needs node:fs / node:child_process / native modules selects the subprocess runtime with runtime: subprocess in its descriptor (host-only by nature); see the Runtime section. |
supportedDrivers?: string[] |
Registration gate, finer-grained than supportedPlatforms. Use when a tool depends on driver-specific capabilities (e.g. "playwright-native" only). |
sensitiveArgNames?: (keyof TInput)[] |
Argument names whose values are masked in the tool-execution log — see Keeping a credential out of the session log for exactly what that covers. Not a registration gate. |
Keeping a credential out of the session log¶
Every tool call is written to the session log, and that log ships as a CI artifact. So a
tool that takes a password, a session token, or a fetched auth payload leaks it by
default. Name those arguments in sensitiveArgNames and the framework replaces each
value with <redacted> at the log-encode boundary:
interface SignInInput {
email: string;
password: string;
}
export const signIn = trailblaze.tool<SignInInput, { ok: boolean }>(
{ sensitiveArgNames: ["password"] },
async ({ email, password }, ctx) => {
// `password` is the real value here — masking is log-only.
return { ok: await authenticate(email, password) };
},
);
The names are typed against your input interface, so it stays the single source of truth.
Renaming password without updating the list, or misspelling it, is a compile error
rather than a silently-unmasked credential:
error TS2820: Type '"passwrod"' is not assignable to type 'keyof SignInInput'.
Did you mean '"password"'?
Four more things worth knowing:
- Masking is log-only. Your handler, and the wire encoding that reaches it, still get the real value. Only the persisted payload is masked.
- Names are top-level input keys. A secret nested inside an object argument isn’t reached; lift it to a top-level field if it needs masking.
- A recording made from a masked log carries the placeholder, not the secret. That’s
intended: re-supply secret material at authoring time (a
{{memory}}token, say) rather than round-tripping it through logs. - What’s covered is the tool-execution log. A tool invoked directly over MCP —
including
trailblaze tool <name> --args …— has its arguments recorded by the MCP request log and the daemon log before this masking applies, so those two surfaces still carry the real value. Anything your handler writes to stderr is copied into the daemon log verbatim too. Replaying a trail doesn’t traverse either path. This gap predates the declaration and applies equally to Kotlin tools; closing it is tracked separately.
The same declaration is available on a YAML descriptor as a top-level
sensitiveArgNames: [password] shortcut, and to a Kotlin class-backed tool by
implementing SensitiveArgsTrailblazeTool — all three lower to the same
_meta.trailblaze/sensitiveArgNames key the runtimes read, and when more than one applies
to a tool their names are unioned, never overridden.
Because the cost of getting this wrong is a credential in a shipped artifact, every ambiguity resolves toward masking. A malformed declaration fails the descriptor at load time; if one somehow reaches a runtime anyway (a hand-written bundle, an external MCP server’s advertisement — neither passes through descriptor validation), that tool’s log masks every argument rather than none.
Where the description comes from¶
Three places can supply the LLM-facing description for a scripted tool. The framework resolves them in this order, with the first one that’s set winning:
- A YAML sidecar’s
description:field (legacy / per-tool YAML shape). - The spec’s
description?:field (when set on thetrailblaze.tool(...)spec). - The TSDoc above the
export const(the default, and what every typed example in this page uses).
The TSDoc default keeps the IDE-hover text and the LLM-facing description identical by
construction, which is what you want for almost every tool. Reach for spec
description when you specifically want to keep implementation notes in TSDoc and ship
a different agent-facing string.
A tool whose spec is { supportedPlatforms: ["web"], requiresContext: true } is the
canonical shape for a UI-driving web tool. Mobile UI tools use
{ supportedPlatforms: ["ios"], requiresContext: true } or ["android"]. Cross-platform
helpers omit supportedPlatforms entirely.
Composing other tools via ctx.tools.<name>(args)¶
The second argument to every handler is the ToolContext. Its tools namespace is a
typed Proxy that exposes every tool reachable from this trailmap:
- Framework primitives brought in by the trailmap’s
platforms.<p>.tool_sets:declarations (e.g.web_core→web_navigate,web_click,web_type). - Sibling scripted tools in this trailmap’s own
target.tools:list. - Transitively-inherited scripted tools that this trailmap’s
dependencies:publish via their ownexports:field.
Calls are typed by the per-trailmap trailblaze-client.d.ts (regenerated on every
trailblaze check and on every daemon-aware command):
// Compose framework primitives:
await ctx.tools.web_type({ ref: SELECTORS.searchInput, text: query });
await ctx.tools.web_click({ ref: SELECTORS.searchSubmit });
await ctx.tools.web_verifyElementVisible({ ref: SELECTORS.firstHeading });
// Compose your own sibling scripted tools (cross-tool composition):
await ctx.tools.contacts_ios_searchContacts({
query: "Albert Einstein",
openFirstResult: true,
});
The proxy throws on failure — if the inner call fails, ctx.tools.<name>(...) throws an
Error you can try/catch. An unknown name is a tsc compile error (the typed
surface deliberately omits a generic callTool so authors can’t bypass type-checking).
Worked composition example¶
contacts_ios_searchAndVerify
is the canonical composition demo — it doesn’t dispatch any iOS primitive directly,
just delegates to two sibling scripted tools and assembles their behaviors into one
higher-level workflow:
export const contacts_ios_searchAndVerify = trailblaze.tool<SearchAndVerifyArgs>(
{ supportedPlatforms: ["ios"], requiresContext: true },
async (input, ctx) => {
const query = nonEmptyString(input?.query, "John Appleseed");
const expectedName = nonEmptyString(input?.expectedName, query);
const requireFields = filterNonEmptyStrings(input?.requireFields);
await ctx.tools.contacts_ios_searchContacts({
query,
rowText: expectedName,
openFirstResult: true,
});
await ctx.tools.contacts_ios_verifyContactStructure({
name: expectedName,
requireFields,
});
return requireFields.length > 0
? `Searched for "${query}", opened "${expectedName}", and verified fields [${requireFields.join(", ")}].`
: `Searched for "${query}", opened "${expectedName}", and verified detail screen.`;
},
);
The agent sees one tool-call worth of latency — the whole composition runs inside one QuickJS invocation. Each sub-tool’s selector knowledge, retry behavior, and assertion shape stays in one place; the wrapper just chooses which primitives to run.
Worked examples¶
Wikipedia: a typed web tool with conditional UI¶
wikipedia_web_searchAndOpenFirstResult.ts
covers the common shape of an action tool that types into a form, optionally submits,
and verifies the destination. nonEmptyString, ensureOn, and isWikipediaHostname
below come from the trailmap’s wikipedia_shared.ts helper module — typical helpers
for a shared module — see Shared helpers.
export interface SearchAndOpenFirstResultArgs {
/** Query to type into the search box. */
query?: string;
/** Heading text to assert on the opened article. Defaults to `query`. */
expectedHeading?: string;
/** Submit the search form (default true). */
openFirstResult?: boolean;
}
/**
* Search Wikipedia from the header search box. Use this whenever the task
* is to search Wikipedia for something — e.g. "search for Albert Einstein",
* "look up Python on Wikipedia", "find articles about Mount Everest"...
*/
export const wikipedia_web_searchAndOpenFirstResult = trailblaze.tool<SearchAndOpenFirstResultArgs>(
{ supportedPlatforms: ["web"], requiresContext: true },
async (input, ctx) => {
const query = nonEmptyString(input.query, "Trailblazer");
const expectedHeading = nonEmptyString(input.expectedHeading, query);
const openFirstResult = input.openFirstResult !== false;
await ensureOn(ctx, isWikipediaHostname, WIKIPEDIA_MAIN_PAGE);
await ctx.tools.web_type({ ref: SELECTORS.searchInput, text: query });
if (!openFirstResult) {
return `Typed "${query}" into Wikipedia search and stopped (no submit).`;
}
await ctx.tools.web_click({ ref: SELECTORS.searchSubmit });
await ctx.tools.web_verifyElementVisible({ ref: SELECTORS.firstHeading });
await ctx.tools.web_verifyTextVisible({ text: expectedHeading });
return `Searched for "${query}" and verified result heading "${expectedHeading}".`;
},
);
iOS Contacts: a typed mobile tool with structured branching¶
contacts_ios_verifyContactStructure.ts
illustrates a verification tool whose contract depends on which fields the caller
demands:
export interface VerifyContactStructureArgs {
/** Contact name to assert in the navbar / heading. */
name?: string;
/**
* Optional list of additional field labels the contact must surface — common
* values: "phone", "mobile", "email", "home", "work". Empty list (the
* default) skips the field-presence assertions.
*/
requireFields?: string[];
}
/**
* Verify the currently-open iOS contact detail screen conforms to an expected
* shape — name heading visible, plus an optional list of required field
* labels ("phone", "email", "home", etc.)...
*/
export const contacts_ios_verifyContactStructure = trailblaze.tool<VerifyContactStructureArgs>(
{ supportedPlatforms: ["ios"], requiresContext: true },
async (input, ctx) => {
const name = nonEmptyString(input?.name, DEFAULT_NAME);
const requireFields = filterNonEmptyStrings(input?.requireFields);
await ctx.tools.assertVisibleWithAccessibilityText({ accessibilityText: name });
if (requireFields.length === 0) {
return `Verified contact "${name}" detail screen rendered.`;
}
const missing: string[] = [];
for (const field of requireFields) {
if (!(await textIsVisible(ctx, field))) {
missing.push(field);
}
}
if (missing.length > 0) {
throw new Error(
`contacts_ios_verifyContactStructure: contact "${name}" missing fields: ${missing.join(", ")}.`,
);
}
return `Verified contact "${name}" with fields [${requireFields.join(", ")}].`;
},
);
Failing with a typed payload: ToolError¶
throw new Error(message) is the default failure surface and stays fine for most
tools: the message becomes the step’s error text. When downstream consumers need to
parse the failure — route it, count it, key triage off it — throw the SDK’s
ToolError instead and attach a JSON payload via data:
import { ToolError } from "@trailblaze/scripting";
throw new ToolError(`example_tool: staging account ${email} is locked out.`, {
data: { schema: "example-repo/tool-error/v1", code: "account-state" },
});
The message renders everywhere the plain-Error message does; the data payload
additionally rides the error envelope (structuredContent) into the session log and
the report row (failure_code is lifted from a top-level string code). Trailblaze
never interprets the payload — your repo owns its schema, so include a schema
discriminator. A plain Error, or a ToolError without data, produces the same
text-only failure as before.
Shared helpers¶
Every trailmap with more than one tool ends up wanting a shared helper module — a single place to keep label constants, selector definitions, and small validators. Both example trailmaps follow the same convention:
- iOS Contacts:
contacts_ios_shared.ts— exports aLABELSfrozen object with every accessibility label, plusensureContactsRoot,textIsVisible,nonEmptyString,filterNonEmptyStrings. - Wikipedia:
wikipedia_shared.ts— exports aSELECTORSfrozen object with every CSS/ARIA selector, plusensureOn,elementIsVisible,articleUrl, and friends.
Helpers take ctx: ToolContext directly (no client argument) and live in
*_shared.ts so the trailmap loader skips them at registration time (they have no
trailblaze.tool(...) export).
Tool descriptions: what the LLM actually sees¶
The TSDoc on each exported const is what the LLM reads to learn what your tool does
(unless you’ve set a higher-precedence source — see
Where the description comes from). Two non-obvious
rules:
- Don’t write “USE THIS TOOL FOR X.” The LLM picks tools by matching the prompt against the description’s prose. Telling it to “use” the tool reduces the description to a single keyword and loses the surrounding context. Describe what the tool does and include the task patterns it matches — “Search Wikipedia for X / look up Y on Wikipedia / find articles about Z” — not “USE THIS WHEN SEARCHING.”
- Match real user phrasing. If a trail says “open Albert Einstein’s contact” but the tool description only mentions “navigate to a contact’s detail screen,” the LLM may not connect them. Include the synonyms (open, view, navigate to, look up) that real prompts will use.
The same care applies to per-field TSDoc. A defaults to "John" note in the field’s
docstring becomes the LLM’s hint that the field is optional and what the implicit
default looks like.
Per-target system prompts complement tool descriptions for the cases where the
agent needs a nudge to prefer your scripted tool over an inline expansion to raw
primitives. Set the path on target.system_prompt_file: in trailmap.yaml — examples:
wikipedia-system-prompt.md,
contacts-ios-system-prompt.md.
IDE typings — trailblaze check¶
Trailblaze emits per-trailmap typings so ctx.tools.<name>(args) autocompletes in your
editor with no hand-authored config — no bun install, no node_modules. After cloning
(or after editing a trailmap.yaml), run:
trailblaze check
It vendors the SDK, emits each trailmap’s tools/trailblaze-client.d.ts (exhaustive types
for every tool that trailmap can dispatch) and a framework-managed tools/tsconfig.json,
then type-checks your tools/*.ts. Hover any ctx.tools.<name> call afterward and the IDE
shows the typed signature and TSDoc; a wrong name or arg shape is a tsc error.
You usually don’t even run it by hand: the daemon fires the same codegen on every
device-aware command, and the committed workspace package.json re-runs it on
npm install / bun install. For the workspace layout (what you write vs. what’s
generated), what each generated file is for, and which ones to commit, see
Scripted Tools — Project Layout & Generated Files.
Testing your tool¶
Pair every .ts tool with a sibling <name>.test.ts file and tests run through the
mock client + mock context from @trailblaze/scripting/testing — no daemon, no device,
no MCP roundtrip:
trailblaze check # materialize + tsc + bun test
trailblaze check --no-typecheck # tests only, skip tsc
The mock helpers satisfy the handler signature:
createMockClient()— returns a client whosetoolsproxy records everyctx.tools.<name>(args)call intoclient.calls. Tests assert call order + arg shapes against that array.client.stub(toolName, { textContent, errorMessage })registers a canned response — a non-emptyerrorMessagemakes the call throw with production’s wording, which exercisestry/catchrecovery branches.createMockContext({ platform, sessionId?, target?, memory? })— returns a context with a no-op logger and sensible test defaults.
Worked composition test from iOS Contacts —
contacts_ios_searchAndVerify.test.ts:
import { describe, expect, test } from "bun:test";
import { createMockClient, createMockContext } from "@trailblaze/scripting/testing";
import { contacts_ios_searchAndVerify } from "./contacts_ios_searchAndVerify";
describe("contacts_ios_searchAndVerify", () => {
test("dispatches searchContacts then verifyContactStructure with the forwarded args", async () => {
const client = createMockClient();
const ctx = createMockContext({ platform: "ios" });
await contacts_ios_searchAndVerify(
{ query: "Apple Inc.", requireFields: ["phone", "email"] },
ctx,
client,
);
// Two cross-tool dispatches, in order. Neither sub-tool is unrolled —
// each sub-tool has its own dedicated test file.
expect(client.calls.map((c) => c.tool)).toEqual([
"contacts_ios_searchContacts",
"contacts_ios_verifyContactStructure",
]);
expect(client.calls[0]?.args).toMatchObject({
query: "Apple Inc.",
rowText: "Apple Inc.",
openFirstResult: true,
});
});
});
Other patterns worth copying live in the same tools/ directory:
contacts_ios_searchContacts.test.ts— single-tool sequence asserting call order + arg shapes + the “No Results” conditional branch.contacts_ios_verifyContactStructure.test.ts— per-field probe loop +client.stubfor fault injection.
Preconditions. bun on PATH (install from https://bun.sh) and a one-time
trailblaze check so tools/tsconfig.json exists; the CLI’s pre-flight surfaces a
directed error pointing back to trailblaze check if the file is missing.
Runtime: QuickJS in-process by default¶
.ts files dispatch through the daemon’s in-process QuickJS runtime — no subprocess
fork, sub-millisecond invocation, no node_modules resolution. The SDK ships curated
runtime globals (URL, fetch, AbortController, console); Node-flavored built-ins
(node:fs, node:child_process, node:os) are not present.
If your tool needs full Node-compatible APIs — node:fs, persistent state, native
modules — select the host subprocess runtime by setting runtime: subprocess in the
tool’s descriptor. The framework spawns a Bun subprocess for that tool’s invocations,
where the Node surface exists; the rest of your trailmap stays in-process. runtime is a
descriptor field, not part of the typed trailblaze.tool(...) spec, so the tool carries a
<name>.yaml descriptor that sets it:
# myapp_writeArtifact.yaml
script: ./myapp_writeArtifact.ts
name: myapp_writeArtifact
description: Write a text artifact on the host.
runtime: subprocess # the runtime selector — dispatch to a host bun subprocess (Node APIs)
requiresHost: true # separate visibility gate — also skip registering on-device
// myapp_writeArtifact.ts
export const myapp_writeArtifact = trailblaze.tool<WriteArtifactArgs>(async (input) => {
const fs = await import("node:fs/promises");
await fs.writeFile(input.path, input.body);
return `Wrote ${input.path}.`;
});
runtime: subprocess and requiresHost: true are independent flags, and a Node-API
tool typically sets both:
runtime: subprocessis the runtime selector — it dispatches the handler to the host Bun subprocess wherenode:*exists. Without it the tool defaults to in-process QuickJS, where anode:fsimport fails. A subprocess can’t run on a device, so such a tool is host-only by nature.requiresHost: trueis only an on-device visibility gate — it skips registering the tool on-device. On its own it does not select a Node runtime, so it alone won’t give a tool Node APIs.
A subprocess tool needs // @ts-nocheck¶
The framework-emitted tools/tsconfig.json describes the on-device program: lib: ["ES2022"],
no @types/node. That’s deliberate — a stray node:fs in an in-process tool has to fail the
typecheck, because it would fail on the device. But the tsconfig covers every non-test file under
tools/, and it excludes only **/*.test.ts, so your subprocess tool lands in that same program and
its Node imports have nowhere to resolve:
myapp_writeArtifact.ts(3,21): error TS2307: Cannot find module 'node:fs' or its corresponding type declarations.
myapp_writeArtifact.ts(9,5): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
Opt that one file out with a // @ts-nocheck line comment, anywhere in the comments above your
first statement. A block comment doesn’t work — /* @ts-nocheck */ suppresses nothing:
// @ts-nocheck
// Host-only: runs in a Bun subprocess (`runtime: subprocess`), so it uses Node built-ins.
// The framework-emitted tsconfig describes the Node-free on-device program, which this file
// is not part of. Bun executes it; nothing typechecks it.
import * as fs from "node:fs";
It’s per file, not per directory, so each host tool you add needs its own. That’s the trade for
keeping the guarantee that matters: an in-process tool that reaches for node:* still fails, because
only the files you marked are exempt.
Be clear about what you gave up. @ts-nocheck suppresses every diagnostic in the file, not
just the unresolvable Node imports, and nothing downstream makes up the difference — bun run and
bun test execute TypeScript, they don’t typecheck it. There is no second pass that catches a bad
handler return type or a misused ctx; an exempt file’s type errors surface when the code path runs.
So keep host tools thin, and cover them with unit tests, which at least execute the code. Tests
aren’t typechecked either — **/*.test.ts is excluded from the emitted program — so the exemption
costs you the same thing on both sides of the file.
If you want a real typecheck for host tools, run a separate tsc pass over them with "types":
["node"], as your own script outside the emitted program. Don’t get there by dropping a
tsconfig.json into host/: a nested config does silence the errors, but it creates a second
program that nothing else knows about — and tooling that discovers tool directories by the presence
of tools/tsconfig.json can read a nested one as “this directory manages its own TypeScript” and
skip the parent, quietly dropping your on-device tools from the check they’re there for.
Composition with other tools still works (the proxy routes through the same daemon), so a
host-only tool can call into in-process tools and vice versa. See
sampleapp_writeArtifact
for a worked example.
For HTTP specifically, you usually don’t need a subprocess at all — the in-process
runtime ships a real fetch. See
Scripted Tools — Network Requests for when fetch
is enough (almost always) and when a subprocess tool is worth its host-only restriction
and per-call overhead.
When you still need a sibling YAML¶
The canonical shape is .ts-only. A sibling <name>.yaml is still supported in a
handful of escape-hatch cases:
- Legacy
export async functiontools. Trailmaps authored before the typed surface landed pair each.tswith a full descriptor YAML (name:,description:,inputSchema:). They keep working unchanged; see Scripted Tools — Legacy Reference for the schema. Migrate when convenient. - Multi-tool files. A
.tsthat exports multipletrailblaze.tool(...)declarations needs at most one YAML per export to register multiple names against the same source file. Most authors keep one tool per file and don’t hit this. - Build-time bundler interop. Trailmaps consumed by the Gradle plugin’s
bundleTrailblazeTrailmaptask (rare — used today by the bundledclocktrailmap) currently need a full descriptor YAML alongside the typed.ts. The daemon-time path reads the analyzer directly and doesn’t need the YAML; the build-time bundler will catch up in a follow-up.
If you don’t recognize yourself in those three cases, you don’t need a YAML — write the
.ts and you’re done.
Common errors¶
| Error | What’s wrong |
|---|---|
Tool not registered: foo (registered tools: alpha, beta) |
The tool wasn’t in the runtime registry when the dispatch fired. Either you didn’t list it under target.tools: in trailmap.yaml, or the daemon hasn’t been restarted since you added the file. Re-run trailblaze check and restart the daemon. |
ValidationError: tool 'foo' received invalid arguments — /query: must be string |
The agent sent a payload that doesn’t match the interface. Often a sign the TSDoc on a field needs sharper guidance, or that a field marked required should be optional. The LLM self-corrects on the next round. |
esbuild failed (exit 1) bundling scripted-tool source /path/to/foo.ts |
Syntax error or unresolved import in your .ts. The full esbuild stderr follows in the same message. |
Trailmap '<id>': target.tools: listed '<path>.tool.yaml', but .tool.yaml files are pure-YAML composed tools that auto-discover... |
You put a pure-YAML tool path under target.tools:. That list is for scripted tools only. Drop the entry; the YAML tool auto-discovers. See Trailmaps → Tool flavors. |
Scripted tool 'X' must export a function with that exact name. Found: undefined. |
The export name doesn’t match what target.tools: is asking for. Either rename the export or update the manifest entry. |
ctx.tools.<X> is a tsc error in the IDE |
The per-trailmap trailblaze-client.d.ts is stale or missing. Run trailblaze check — the codegen rewrites bindings against the current registry. |
Where to go next¶
- Your First Trailmap — workspace-level walkthrough from empty directory to running tool.
- Trailmaps — manifest schema, dependencies + defaults, tool flavors, discovery + precedence.
- Scripted Tools — Project Layout & Generated Files — workspace layout (source vs. generated), what each generated file is for, what to commit, and the fresh-clone bootstrap.
- Scripted Tools — Network Requests — making
HTTP calls from a tool: in-process
fetch(the default, available on host and on-device) vs. a subprocess tool (host-only) for whatfetchcan’t reach. examples/ios-contacts/README/examples/wikipedia/README— the worked references this page draws from, with their own quick-starts and CI notes.- Publishing a Trailmap — when you want to share your trailmap with other teams as a vendored bundle or an npm package.
@trailblaze/scriptingAuthoring Vision — the conceptual background on why scripted tools exist and how they fit into the agent loop.