Skip to content

runTrail: Trail-as-Tool Primitive (Proposal)

Summary

Forward-looking proposal for a runTrail tool: a DelegatingTrailblazeTool that invokes another .trail.yaml file and returns its tool calls as the delegation expansion. Surfaced during the Maestro comparison (2026-04-21-maestro-scripting-and-control-flow-comparison.md) as the Trailblaze analog to Maestro’s runFlow, but implemented as a tool rather than a YAML keyword so it composes with existing delegation, recording, and scripted-tool infrastructure. Not yet scoped or scheduled.

What runTrail would be

A single new tool:

- runTrail:
    path: trails/edges/home-to-alarm-tab.trail.yaml
    params:
      alarm_time: "07:00"

Implementation shape: a DelegatingTrailblazeTool whose toExecutableTrailblazeTools(ctx) loads the referenced trail file, walks its trail: objective list, and returns the expanded tool calls. Identical behavior to every other delegating tool — top-level call is recorded, expansion is recorded underneath.

That’s the whole proposal. The infrastructure it needs already exists.

Why this fell out of the Maestro comparison

Maestro’s runFlow is function-call semantics for YAML flows (pass an env: block, invoke a sub-flow, continue). Trail YAML today has no equivalent — if you want to reuse a sequence of steps across trails, your options are: (a) a YAML-defined tool (Decision 037), (b) a Kotlin tool, © a scripted tool. All three require elevating the sequence into the tool registry.

runTrail gives a fourth option: reuse a trail file as-is, in place, by path. The trail file doesn’t need to be registered, doesn’t need a tool ID, doesn’t need a separate name. You point at it and run it.

Why implement it as a tool, not a YAML keyword

Maestro made runFlow a YAML keyword. We shouldn’t, for a few reasons that flow directly from our existing architecture:

  • Flat-YAML principle holds. Trail YAML is a list of objectives, each with tools. runTrail is just another tool — the trail stays flat and uniform.
  • Recording works for free. Delegating tools already record both layers (the top-level call and the expansion). runTrail inherits that behavior automatically. Making it a YAML keyword would require a new recording code path.
  • Scripted tools can invoke it. trailblaze.execute("runTrail", { path, params }) works the same way as any other tool invocation. A scripted tool can pick which trail to run based on runtime state. A YAML keyword couldn’t be invoked from a script without a second mechanism.
  • Uniform with everything else in the registry. Tools are the composition unit. Introducing a parallel composition unit (special keywords) is a complexity multiplier everyone has to learn.

This is one of the non-obvious wins of having taken the “logic-in-tools” path in the first place: a feature that Maestro needed a dedicated YAML keyword for is a one-tool addition for us.

How it slots into the existing model

Trail v2 syntax (2026-03-06-trail-yaml-v2-syntax.md)

No new syntax. runTrail is a tool like any other, used under an objective:

trail:
  - objective: Reach the alarm tab
    tools:
      - runTrail:
          path: trails/edges/home-to-alarm-tab.trail.yaml
  - objective: Create a 7am alarm
    tools:
      - tap: "+"
      - inputText: { text: "07:00" }
      - tap: "Save"

Scripted tools (Decisions 025/038)

Scripted tools can branch on runtime state and select which trail to invoke:

const waypoint = trailblaze.execute("whereAmI", {});
if (waypoint.type === "Success" && waypoint.message === "alarm-tab") {
  return; // already there
}
trailblaze.execute("runTrail", {
  path: "trails/edges/home-to-alarm-tab.trail.yaml",
});

This is the composition pattern that lets nav-graph pathfinding (Decision 028) be implemented as an ordinary scripted tool rather than a framework primitive — pathfinder chooses edges, calls runTrail on each. Left as a thread to pick up in the waypoints follow-up devlog.

Memory / param scope (needs to be pinned)

The one design choice that isn’t free. Options:

  1. Shared scope (inherit parent memory, writes propagate back). Simple; matches how tools behave today within a single trail.
  2. Declared params, writes propagate. Child trail declares what inputs it accepts (via its trailhead.memory with defaults); caller passes params:; writes still propagate to parent memory.
  3. Isolated scope. Child gets its own memory; caller sees nothing back. Safest, least ergonomic.

Tentative preference: option 2. Matches how tool params already work, matches Maestro’s runFlow: env: shape, and keeps writes visible so a called trail that populates user_id into memory is useful to the caller. But this is the one thing worth explicitly deciding before implementation rather than picking by default.

Design choices to pin before implementation

  • Memory/param scoping rule (see above). Probably option 2, worth confirming.
  • Failure semantics. If a step inside the called trail fails, does the whole parent trail fail? Default yes (consistent with any other tool failure), but a continueOnError option might be worth it for optional cleanup trails. Defer unless a real case shows up.
  • Recursion depth. A trail that calls itself (directly or via a chain) could loop. Same bounded-recursion cap that Decision 038 already established for scripted tool reentrance (~16 deep) applies uniformly — cap at the delegation layer, not per-tool.
  • Path resolution. Absolute vs relative-to-caller vs relative-to-a-trails-root. Lean toward relative to the caller’s trail file, matching how most import systems work and keeping trails portable across checkouts.
  • Should the expansion be visible to the LLM? For scripted-tool-invoked runTrail, probably not — the LLM sees a completed runTrail result, not the expanded steps. For trail-YAML-invoked runTrail during replay, it’s a no-op question (LLM isn’t involved). Resolves naturally by treating runTrail as isForLlm = true but isRecordable = true — same as other delegating tools.

Out of scope for this devlog

  • Waypoint integration (startAt / endAt assertions bracketing a trail). Genuinely interesting but larger than runTrail itself — belongs in the waypoints follow-up devlog, not this one. The bare runTrail primitive is useful independently; waypoint assertions layer on top.
  • Nav-graph discovery / pathfinding. Same deferral. runTrail is a prerequisite primitive; graph-driven navigation is the layer that uses it.
  • Trail file format changes. runTrail should work against existing v2 trail files without modification. Anything that requires v2 schema changes gets deferred to the waypoints work.

Not a decision

Recording this as a proposal, not as a merged decision. Next step when someone picks this up: pin the scoping rule (question 1 above), write a scope devlog, implement. Expected footprint is small — one new TrailblazeTool class, a trail loader that the delegation point already has access to, tests for the scoping rule.

  • Maestro Scripting & Flow Control — Comparison and Self-Validation — where this proposal originated
  • Decision 002: Trail Recording Format (YAML) — recording model runTrail plugs into
  • Decision (v2 syntax): 2026-03-06-trail-yaml-v2-syntax.md — trail file shape runTrail invokes
  • Decision 025 / 037 / 038: the existing tool authoring modes runTrail sits alongside
  • Decision 028: Waypoints and App Navigation Graphs — the larger design context that a future devlog will tie together with this primitive