Packs¶
A pack is a self-contained directory of Trailblaze configuration that wires up a target, its toolsets, scripted tools, and waypoints together. Packs are the recommended unit of authoring going forward — a workspace declares which packs it loads, and each pack groups everything needed to test one app surface in one directory.
Layout¶
A pack lives in a directory and is anchored by a pack.yaml manifest:
my-workspace/trailblaze-config/
├── trailblaze.yaml # workspace anchor — declares which packs to load
└── packs/
└── myapp/
├── pack.yaml # the manifest
├── tools/
│ ├── myapp_login.yaml # one scripted tool per file
│ └── myapp_navigate.yaml
├── toolsets/ # optional pack-local toolsets
│ └── myapp_extras.yaml
└── waypoints/ # optional waypoints owned by this pack
└── myapp-home-screen.waypoint.yaml
trailblaze.yaml references the pack:
packs:
- packs/myapp/pack.yaml
The pack.yaml manifest¶
id: myapp
target:
display_name: My App
tools: # per-file scripted tool refs
- tools/myapp_login.yaml
- tools/myapp_navigate.yaml
platforms:
android:
app_ids:
- com.example.myapp
tool_sets:
- core_interaction
- verification
- myapp_extras # pack-local toolset
toolsets: # pack-level file refs
- toolsets/myapp_extras.yaml
waypoints:
- waypoints/myapp-home-screen.waypoint.yaml
Field reference¶
| Field | Status | Purpose |
|---|---|---|
id |
required | Unique pack id. Used for shadowing / overrides. |
target |
optional | Embeds an AppTargetYamlConfig (id is inherited from the pack id when omitted). A pack without target: is treated as a library pack — it contributes via defaults: / toolsets: / tools: / waypoints: but isn’t surfaced as a runnable target. target.tools: lists per-file scripted tool YAML paths under the pack directory. |
target.system_prompt_file |
optional | Pack-relative path to a markdown / text file containing the target’s system-prompt template. The pack loader (and the build-time generator) reads the file and inlines its content into the generated target YAML’s system_prompt: field. Lets authors keep multi-paragraph prompts in a standalone editable file rather than an unwieldy YAML string. Inline system_prompt: is not supported on target — a pack manifest declaring it fails the load with a migration message. The file-only shape leaves room for future per-device or per-classifier prompt selection (e.g. app-tablet.prompt.md) without an authoring schema change. |
dependencies |
optional | Pack ids this pack depends on. Transitive — depending on a pack pulls in its dependencies too. Resolves via closest-to-root-wins inheritance from each dep’s defaults: (see below). |
defaults |
optional | Per-platform defaults this pack contributes to consumers via dependency resolution. Same shape as target.platforms. Consumers with dependencies: [<this pack>] inherit any field they leave null. |
toolsets |
optional | List of pack-relative paths to ToolSetYamlConfig files. |
tools |
optional | List of pack-relative paths to ToolYamlConfig files (class-backed or YAML-composed). Distinct from target.tools:, which holds scripted-tool refs (PackScriptedToolFile). |
waypoints |
optional | List of pack-relative paths to WaypointDefinition files. Wired through loadResolvedRuntime() and surfaced to the trailblaze waypoint CLI. |
trails |
reserved | First-class artifact loading deferred. |
The
routes:field was removed in 2026-04-28 — routes were dropped as a separate concept in favor of “shortcuts that invoke other shortcuts.”The legacy reserved-slot fields
use:/extend:/replace:were removed in favour of the unifieddependencies:field. Workspace packs that still declare them get a one-shot deprecation warning at load time; migrate todependencies:to restore composition.
Composition via dependencies: and defaults:¶
A pack composes other packs by listing their ids:
id: contacts
dependencies:
- trailblaze
target:
display_name: Google Contacts
platforms:
android:
app_ids: [com.google.android.contacts]
ios: {}
web: {}
compose: {}
The framework ships a single trailblaze pack that publishes the standard per-platform
defaults. The conventional consumer preamble is dependencies: [trailblaze]. Resolution
rules:
- Field-level closest-wins. For each platform key the consumer declares, every field is resolved independently: the consumer’s own non-null value wins; otherwise the dep-graph walk picks the value from the closest-depth contributor.
- No list concatenation. A consumer that writes
tool_sets:for a platform replaces the inherited list entirely. This preserves the per-platform listing as visible documentation — authors who want explicittool_sets:listings on every platform keep them; authors who want a one-line target file omit and inherit. - Tie-break: later-declared at same depth wins. When two contributors at the same depth both supply a field for the same platform, the later one in DFS declaration order wins.
- Platform set comes from the consumer. Defaults only fill in fields for platforms
the consumer explicitly declares. A consumer that wants e.g.
ioswith all defaults writesios: {}— the empty map is the explicit signal “this platform exists, fill in everything from defaults.”
The migrated contacts pack (39 lines → 18 lines) is a worked example — see
trailblaze-models/src/commonMain/resources/trailblaze-config/packs/contacts/pack.yaml.
Tool flavors: which kind do I write?¶
A pack can contribute two flavors of custom tool. They share the <pack>/tools/
directory but bind through entirely different mechanisms — easy to copy-paste-confuse,
so this table is the rubric:
| Scripted (TypeScript) | Pure-YAML composed | |
|---|---|---|
| Filename | <id>.yaml + <id>.ts (pair) |
<id>.tool.yaml (note the double suffix) |
| Body | JavaScript/TypeScript in the sibling .ts |
tools: block — declarative composition of existing tools |
| Manifest entry | Listed under target.tools: in pack.yaml |
Auto-discovered — do NOT list in pack.yaml |
| Surfaced to a target via | Direct target.tools: reference |
A toolset (<workspace>/trails/config/toolsets/<id>.yaml or <pack>/toolsets/<id>.yaml) that names the tool, declared from platforms.<p>.tool_sets: |
| Param schema field | inputSchema: (map keyed by param name) |
parameters: (list of {name, type, required?, description?}) |
| Host vs on-device | Honors requiresHost: true on the descriptor (gate at registration time) |
Workspace tools default to requires_host: true (config can’t ship on-device); bundled framework *.tool.yaml runs on either side because the file is on both classpaths |
| Pick when | You need branching, retries, async, multi-step orchestration | You need a thin wrapper that substitutes parameters into existing tool calls |
The two most common mistakes when authoring a pack from scratch:
-
Listing a
.tool.yamlundertarget.tools:inpack.yaml. The framework treatstarget.tools:entries as scripted tool descriptors and tries to decode each one as aPackScriptedToolFile(which needsscript:andname:). The loader now intercepts.tool.yamlpaths and emits an actionable diagnostic that names the offending path and points at the auto-discovery convention. If you seePack '<id>': target.tools: listed '<path>.tool.yaml', but .tool.yaml files are pure-YAML composed tools that auto-discover..., delete that entry from the manifest — leave the file in place. -
Using
parameters:(list) in a scripted descriptor orinputSchema:(map) in a pure-YAML composed tool. Each flavor uses a different shape; the other shape is silently ignored by the YAML decoder. Symptom: the tool registers but params behave as if the schema were empty. Check the columns above when params aren’t flowing.
Per-file scripted tools¶
This section is the schema reference. For a step-by-step authoring walkthrough that
covers the .ts source, the descriptor, the pack-manifest entry, and the runtime
composition contract, see Author Your First Scripted Tool.
Each entry under target.tools: is a path to a YAML file with this shape:
script: ./examples/myapp/trailblaze-config/tools/myapp_login.js
name: myapp_login
description: Sign into MyApp with the supplied credentials.
_meta:
trailblaze/supportedPlatforms: [android]
trailblaze/requiresContext: true
inputSchema:
email:
type: string
description: Email to enter into the login form.
password:
type: string
description: Password to enter into the login form.
The pack loader translates the flat inputSchema into a JSON-Schema-conformant object
({ type: object, properties: { ... }, required: [ ... ] }) before handing it to the runtime.
See PackScriptedToolFile
for the field-level conventions:
required: trueis the per-property default — setrequired: falsefor optional params.enum: [a, b, c]constrains a string parameter to a fixed set._meta.trailblaze/supportedPlatformsis case-insensitive ([web],[WEB],[Web]all collapse to the canonical form at parse time).script:paths resolve from the JVM working directory (typically the repo root), not the pack directory — the subprocess execution model anchors at CWD. This is documented in the kdoc onPackScriptedToolFile.script.
Tool YAML file suffixes — .tool.yaml, .shortcut.yaml, .trailhead.yaml¶
Each operational class lives under its own pack subdirectory and uses a matching filename
suffix. The loader enforces that the file’s content matches what the suffix promises — a
.tool.yaml file with a stray shortcut: block is a load-time error.
| Suffix | Pack subdir | Class | Available when | Required block |
|---|---|---|---|---|
*.tool.yaml |
tools/ |
tool | a toolset names it under tools: |
(none) |
*.shortcut.yaml |
shortcuts/ |
shortcut | current waypoint matches from |
shortcut: |
*.trailhead.yaml |
trailheads/ |
trailhead | always (bootstrap from any state) | trailhead: |
Subdirectories below each top-level dir are organizational only — the loader walks them
recursively at any depth. A pack with multi-platform shortcuts can group them as
shortcuts/{android,ios,web}/... (or any other grouping that fits) without changing how
discovery works.
The three classes share one data class (ToolYamlConfig) with two optional metadata
blocks (shortcut, trailhead); they’re mutually exclusive — a tool can’t be both a
shortcut and a trailhead.
Workspace
*.tool.yamlauto-discovery. A workspace-authored pure-YAML composed tool is discoverable by the resolver at session start (no manifest entry needed), but the framework only surfaces it to a target when some toolset names it undertools:. The common shape is one workspace toolset at<workspace>/trails/config/toolsets/<id>.yaml(or<pack>/toolsets/<id>.yaml) that lists the tool, and one target referencing that toolset underplatforms.<p>.tool_sets:. Workspace tools default to host-side execution (requires_host = trueimplicit) — the framework’s bundled*.tool.yamlresources ride on both host and device classpaths and execute on whichever side runs them, but workspace files only live on the host, so the dispatcher routes them locally. Authors who explicitly setrequires_host: falsekeep that explicit value.
Shortcut tools¶
A shortcut is a tool with a populated shortcut: { from, to } block — an authored
navigation edge between two waypoints with a runtime pre/post-condition contract. Same
file format, same registry, same agent-facing tool descriptor as any other tool; the
framework adds a contextual descriptor filter (only surfaces shortcut tools whose from
matches the current waypoint) and a pre/post-condition wrapper at execution time.
# packs/clock/shortcuts/clock_create_alarm.shortcut.yaml
id: clock_create_alarm
description: Create an alarm at a given time.
parameters:
- name: hour
type: integer
required: true
- name: minute
type: integer
required: true
shortcut:
from: clock/android/alarm_tab
to: clock/android/alarm_saved
tools:
- tapElement: { selector: { textRegex: 'Add alarm' } }
- inputText: { text: '{{params.hour}}:{{params.minute}}' }
- tapElement: { selector: { text: 'OK' } }
The metadata block adds:
from/to— slash-namespaced waypoint ids the framework matches against current state. Both are required; the framework refuses to invoke iffromdoesn’t match at call time and reports failure if the post-state doesn’t matchto.variant— optional disambiguator when multiple shortcuts share the same(from, to)pair. Most shortcuts never need it.
Trailhead tools¶
A trailhead is a tool with a populated trailhead: { to } block — a bootstrap
primitive that takes the agent from any state to a known waypoint. Always available
(no from precondition). The framework asserts the agent landed at to after the
body runs, just like a shortcut’s post-condition. Trailheads are the right shape for
“launch the app and reach a logged-in screen”, “force-quit and re-sign-in”, and similar
reset/genesis moves that need to work regardless of where the agent currently is.
# packs/myapp/trailheads/myapp_launchAppSignedIn.trailhead.yaml
id: myapp_launchAppSignedIn
description: Launch MyApp and sign in to the home screen.
parameters:
- name: email
default: '{{memory.email}}'
- name: password
default: '{{memory.password}}'
trailhead:
to: myapp/android/home_signed_in
class: com.example.myapp.LaunchAppSignedInTool
A trailhead is one reusable atom. Trail-level setup (the trail’s own trailhead.setup:
section in the v2 trail YAML) composes one or more trailheads alongside other tools —
e.g. setting a feature flag before invoking a launch trailhead. The trailhead is the
atom; the trail’s setup is the orchestration.
Authoring forms¶
The body of any of the three classes uses one of class: (Kotlin-backed) or tools:
(declarative YAML composition). The tools: form is the happy path — machine-readable,
no conditionals, no loops, covers the majority case. A future script: mode for TS/JS
bodies will land when shortcuts/trailheads need real code (branching, retries) the YAML
form can’t express.
Discovery and precedence¶
Packs come from two sources at runtime:
- Workspace packs declared by the workspace’s
trailblaze.yaml(packs:list). - Classpath packs discovered automatically from any
trailblaze-config/packs/<id>/pack.yamlresource shipped on the JVM classpath (framework-bundled examples likeclock,wikipedia, plus any dependency that ships its own packs).
When a workspace pack and a classpath pack share an id, the workspace pack wholesale shadows the classpath one — none of the classpath pack’s target / toolsets / tools / waypoints leak through. This precedence rule is the operative knob: to override a framework-bundled pack, declare a workspace pack with the same id and author whatever subset you need.
If two classpath jars both ship a pack with the same id, the loader keeps the first one discovered and logs a warning identifying both locations — resolve by ensuring only one bundled jar declares each pack id.
Compile output: trails/config/dist/¶
trailblaze compile is the pack→target compile step (think javac for packs). It reads
your workspace’s pack manifests under trails/config/packs/<id>/pack.yaml, walks the
dependency graph with closest-wins inheritance, validates every tool_sets:,
drivers:, tools:, and excluded_tools: reference against the discovered pool, and
emits one materialized <id>.yaml per app pack into trails/config/dist/targets/.
trails/config/
├── trailblaze.yaml # workspace anchor
├── packs/ # SOURCE — author here, commit here
│ ├── myapp/pack.yaml
│ └── shared-toolset/pack.yaml
└── dist/ # OUTPUT — generated, NEVER commit
└── targets/
└── myapp.yaml
The dist/ directory is in .gitignore by default — its contents are a build artifact,
not source. The compile step is idempotent: stale <id>.yaml files left in dist/targets/
from a previous compile that no longer correspond to a current pack are deleted
automatically (orphan cleanup). The compiler only manages files that bear its
# GENERATED BY trailblaze compile. DO NOT EDIT. banner — hand-authored YAMLs in
dist/targets/ are left alone.
The framework JAR’s bundled targets (under
trailblaze-models/src/commonMain/resources/trailblaze-config/targets/) are
intentionally checked-in build outputs of the same compile step run at framework build
time via the trailblaze.bundled-config Gradle plugin — that’s a different lifecycle
from workspace dist/ and explicitly NOT covered by the gitignore rule. See
trailblaze-models/build.gradle.kts for how the plugin is wired and
build-logic/src/main/kotlin/TrailblazeBundledConfigPlugin.kt for the
generateBundledTrailblazeConfig / verifyBundledTrailblazeConfig task pair.
Compile-time validation:
- Missing or cyclic
dependencies:→ compile error names the offending pack so you can jump straight to the manifest. - Typo in
tool_sets:,tools:,excluded_tools:, ordrivers:→ compile error names the target id, platform, field, and unknown reference. The runtime resolver’s graceful “skip the broken pack and keep going” behavior is overridden at compile time so authors see “compilation errors” before runtime sees a silently-missing capability. - Pack manifest YAML parse error → compile error names the offending pack ref.
Authoring¶
Authoring rule of thumb: prefer one pack per app surface. The clock pack ships waypoints, the contacts example ships scripted iOS tools, the playwright sample ships web fixture tools. Each is independently mountable, debuggable, and reviewable in one place.
When in doubt, look at the working examples:
examples/ios-contacts/trailblaze-config/packs/contacts/examples/playwright-native/trailblaze-config/packs/playwrightsample/trailblaze-models/src/commonMain/resources/trailblaze-config/packs/clock/
Migration notes¶
Pack id ioscontacts → contacts¶
The iOS Contacts example workspace pack was renamed from ioscontacts to contacts so the
same pack can later host Android / web tools alongside the iOS ones (the pack is the
Contacts app pack, not just the iOS subset). CLI users who pinned --target ioscontacts
should update to --target contacts. Within this example workspace, the new id shadows the
bundled framework contacts pack (Google Contacts) per the precedence rule above.
From a flat targets/<id>.yaml to a pack¶
A flat target like:
# targets/myapp.yaml
id: myapp
display_name: My App
platforms:
android: { ... }
tools:
- script: ./tools/myapp_login.js
name: myapp_login
description: ...
inputSchema: { type: object, properties: { ... } }
becomes:
packs/myapp/
├── pack.yaml # id, target.display_name, target.tools refs, platforms
└── tools/
└── myapp_login.yaml # script, name, description, flat inputSchema
with the workspace’s trailblaze.yaml adding packs: [packs/myapp/pack.yaml].
The targets/myapp.yaml flat form is still supported for now (legacy-only); new authoring
should use packs. The flat-target tools list (tools: [...] with inline InlineScriptToolConfig
entries) is preserved verbatim for legacy use, but the per-file PackScriptedToolFile shape
with flat inputSchema is the recommended path.