Trailblaze Decision 038 Amendment: Toolset Consolidation¶
Further amended 2026-04-20 by the Option-2 amendment (see § Option-2 Amendment at the bottom of this document). That amendment collapses the former PR A3 (in-process QuickJS toolsets) and PR A4 (subprocess MCP) into a single host-side PR A3: a bun/node subprocess speaking stdio MCP, using the real MCP SDK. QuickJS moves exclusively to PR A5 (on-device bundle). If you’re landing here to pick up Decision 038 work, read the Option-2 Amendment first — the phase table and action plan in the middle of this document are historical, and any earlier sections that describe the pre-Option-2 authoring shape (including the old
tools[]+execute()toolset export contract) are also superseded. Authors now use the real MCP SDK (server.registerTool(...)orserver.setRequestHandler(...)) directly.
Supersedes the inline-script: authoring surface established in PR A and PR A2
(both merged). Re-sequences the remaining Decision 038 work around a single
universal authoring pattern: one .ts toolset file = one or more registered
tools.
The decision¶
All TS-authored Trailblaze tools come from toolset files. Never inline JS in YAML. Ever.
- Inline
script: { source: "..." }(PR A’s current shape) is deprecated. The data-class field stays for binary compatibility with the just-merged PR A; docs, examples, and new work do not use it. script: { file: "./x.ts" }(proposed in this conversation, not implemented) is rejected. Single-file-single-tool is inconsistent with the toolset pattern and creates a second mental model.- The universal authoring pattern is: author writes a
.tstoolset file exporting{ tools: Tool[], execute(toolName, params): CallToolResult }. The toolset registers at config level; individual tools become globally addressable in trail YAML and visible to the LLM (per each tool’s_meta["trailblaze/isForLlm"]key).
This matches the MCP subprocess (2026-04-20-scripted-tools-mcp-subprocess.md)
and on-device bundle (2026-04-20-scripted-tools-on-device-bundle.md) devlogs
already in main — those already specified toolset-per-file. This amendment
extends the same pattern to the in-process case (host-side QuickJS, no bundler),
which was the missing piece.
Why consolidate¶
- One mental model. Authors learn “write a toolset file,” not “write an inline script OR a toolset depending on context.” The deployment mode (in-process / subprocess / on-device bundle) is a config-level concern, not an authoring concern.
- Symmetry across deployment modes. The same
.tssource can potentially run in-process, as a subprocess, or bundled on-device. Capability-based filtering (trailblaze/requiresHostper the conventions devlog) decides what registers where. - Kills inline JS in YAML. Load-bearing ergonomics fix — inline JS past ~3 lines is unmaintainable (escaping, no IDE support, no types, no lint, no tests).
Revised sequencing¶
Previously (now obsolete):
- PR A (merged) — inline script: with source:
- PR A2 (merged) — trailblaze.execute() callback
- PR A3 (in-flight) — subprocess MCP toolsets
- PR A4 — on-device bundle
Revised (this amendment):
| Phase | Scope | Status |
|---|---|---|
| PR A (done) | QuickJS bridge primitive — inline source: is now legacy, not recommended. |
Merged |
| PR A2 (done) | trailblaze.execute() callback. The primitive stays — it’s the execution contract toolsets will use internally. |
Merged |
| PR A3 [NEW / renumbered] | In-process TS toolsets. Host-side QuickJS, no bundler, no subprocess. trailblazeInProcessToolsets: config section. Replaces inline script: as the recommended authoring pattern. |
Next up — start here |
| PR A4 [was A3] | Subprocess MCP toolsets. Same .ts file format, Node runtime, stdio MCP transport. Bumped from PR A3. |
Deferred until new PR A3 lands |
| PR A5 [was A4] | On-device bundle toolsets. Same .ts file format, Gradle-plugin-bundled for QuickJS on-device. Bumped from PR A4. |
Deferred until PR A4 lands |
| PR A4 Phase 2 (was 1B) | Callback direction. Subprocess → Trailblaze primitives via HTTP proto-JSON per Decision 029. Applies to PR A4 (subprocess); PR A5 (on-device) uses the in-process binding instead. | Deferred |
The renumbering is annoying but the inserted in-process-toolsets step is load-bearing — without it, subprocess and on-device would each have their own toolset-loading story, and we’d never have a host-side-only toolset path.
State at time of this amendment¶
The subprocess-MCP branch (the branch where the old PR A3 was in flight): - Contains only documentation commits — the scope-capture devlog for the old PR A3 (subprocess MCP, now renumbered to PR A4) and a few documentation polish commits. - No implementation code exists on this branch.
The in-flight draft PR for the old PR A3: - Titled “PR A3 Phase 1: subprocess MCP toolsets.” - Will be closed with a pointer to this amendment. The subprocess work it was scoping is still valid — just renumbered to PR A4 and blocked on the new PR A3 landing first.
The subprocess-impl devlog (2026-04-20-scripted-tools-a3-subprocess-impl.md,
on this branch, not yet in main):
- Will be marked as “superseded / deferred” at the top, pointing readers here.
- Content is still the design target for the eventual subprocess PR (new PR A4),
so it doesn’t get deleted — just flagged.
Action plan for the next agent¶
Don’t start by coding. Start by aligning on the new PR A3 scope.
-
Create a fresh branch off main for the new PR A3 (in-process TS toolsets). Suggested slug:
scripted-tools-inprocess-toolsets(prefix with your team’s usual branch-naming convention). -
Write the new PR A3 scope devlog —
2026-04-20-scripted-tools-a3-inprocess-toolsets.md. Load-bearing contents: - Config shape:
trailblazeInProcessToolsets: [{ file: "./tools/x.ts" }](top-level intrailblaze.config.yaml). - Toolset file format:
.tsfile exportingtools: Tool[]+executefunction. Matches the MCP conventions devlog’s types (TrailblazeToolwith_meta["trailblaze/*"]for metadata). - TS→JS build: author’s responsibility (run
tsc/esbuild), Trailblaze resolves.ts→ sibling.js, clear error if missing. - Registration: at session start, for each declared toolset, evaluate the
.jsin QuickJS, read thetoolsexport, register each entry as anExecutableTrailblazeToolwhoseexecute()routes through the toolset file’sexecute(toolName, params)function. _trailblazeContextenvelope injected into each call per the conventions devlog — same shape as the subprocess path will use.- Capability filtering:
_meta["trailblaze/requiresHost"]: truetools are registered for in-process mode (this is host-side);_meta["trailblaze/requiresHost"]: falsetools are in-scope for later on-device bundling. -
script: { source: "..." }is legacy: docs stop mentioning it; data-class field stays for binary compat. No runtime warning — the form stays load-bearing for PR A’s published artifact contract. -
Commit the scope devlog, open a draft PR, start implementation:
- Toolset-file loader (evaluate
.js, extracttools[], register each). - Dispatcher for toolset tools (routes each call to the toolset’s
execute). - Config parsing for
trailblazeInProcessToolsets:. -
Tests: fixture toolset file, verify registration, verify dispatch, verify
_trailblazeContextinjection, verify capability filtering. -
After new PR A3 merges, pick up the old subprocess work as renumbered PR A4. That work’s design (in the
-a3-subprocess-impl.mddevlog) is still valid — just deferred.
Open questions for the next agent to resolve¶
These were flagged during implementation planning but not decided:
- Module home for toolset loading. Options: extend
:trailblaze-scripting(natural, already holds QuickJS integration) vs. new:trailblaze-scripting-toolsetsvs. inline into:trailblaze-models. Pick based on dependency fit. - Relationship of
trailblaze.execute()(PR A2) to toolset-file dispatch. The PR A2 binding is still exposed to toolset files (same engine). Toolsetexecutefunctions can calltrailblaze.execute("anotherToolName", ...)to compose across tools. Decide whether this cross-composition is “same toolset only” or “any registered tool in the session.” - Capability-filter default. What does
trailblaze/requiresHostdefault to when not declared? Per the conventions devlog: default isfalse. Confirm during implementation that the default is right for the in-process path. - Dev-loop ergonomics. If the user edits
x.tsand forgets to rebuild, do we just error, or can Trailblaze detect the stale.jsand surface a helpful message? Phase-1 answer: clear error, user re-runs their build.
What NOT to do¶
- Never accept inline JS/TS in YAML.
script: { source: "..." }stays load-bearing-deprecated. Don’t re-open it; don’t generalize it; don’t expose aninline:field on toolset configs. - Never generate
.tsinside Trailblaze’s Kotlin code. Authors write TS; Trailblaze reads.jsbuild artifacts. One direction only. - Never make up numbered decisions without cross-checking the existing
devlog index. The existing devlog directory has the authoritative numbering;
docs/devlog/index.mdis the generated view.
References¶
docs/devlog/2026-04-20-scripted-tools-execution-model.md— Decision 038 umbrella. Will have a brief “revised sequencing” amendment added inline.docs/devlog/2026-04-20-scripted-tools-mcp-subprocess.md— PR A4 (was A3) direction. Unchanged content; re-sequenced after new PR A3.docs/devlog/2026-04-20-scripted-tools-on-device-bundle.md— PR A5 (was A4) direction. Unchanged content; re-sequenced.docs/devlog/2026-04-20-scripted-tools-mcp-conventions.md— shared conventions across all toolset deployment modes._trailblazeContext,_meta["trailblaze/*"],isError/_meta.trailblazeresult shape. Apply to the new PR A3 in-process path unchanged.docs/devlog/2026-04-20-scripted-tools-a2-sync-execute.md— PR A2 (merged).trailblaze.execute()primitive unchanged; authoring-surface deprecation noted here rather than amending the merged devlog.docs/devlog/2026-04-20-scripted-tools-a3-subprocess-impl.md— now an artifact on the obsolete branch. Will be marked superseded at the top.docs/devlog/2026-02-03-custom-tool-architecture.md— Decision 029, the canonical RPC/proto architecture the subprocess callback direction implements when PR A4 Phase 2 eventually lands.
Option-2 Amendment (2026-04-20)¶
Supersedes the phase table and action plan above. Driving observation: the
author’s .ts toolset file should double as a standard MCP server (runnable
under Claude Desktop, Cursor, or any MCP client) with zero authoring fork.
QuickJS can’t run the official @modelcontextprotocol/sdk verbatim — the SDK
uses Node APIs (process.stdin, streams, Buffer). So the host-side runtime
choice pivots: bun/node subprocess speaking stdio MCP, using the real SDK.
This collapses the former PR A3 (in-process QuickJS toolsets) and PR A4
(subprocess MCP) into a single host-side PR A3. QuickJS retains a home only
in PR A5 (on-device bundle), where subprocess isn’t possible and a bundler
produces a QuickJS-runnable artifact via an in-process MCP transport (see
2026-04-20-scripted-tools-on-device-bundle.md).
Key decisions locked in¶
- Host-side runtime is a bun/node subprocess. Trailblaze acts as an MCP client; the subprocess is the server. No QuickJS on the host side for toolset execution.
- Author’s
.tsfile is a standard MCP server. Uses the real@modelcontextprotocol/sdk, wiresStdioServerTransport, runs under any MCP client in addition to Trailblaze. No Trailblaze-specifictools[]/execute()export shape. _trailblazeContextis the sole Trailblaze-specific extension point at runtime. Injected by Trailblaze as a reserved key insideargumentsper the MCP conventions devlog. Pure MCP clients don’t inject it, so the field isundefinedthere.- Tier 1 / Tier 2 is declared by method signature, not a required flag.
Tools that destructure
_trailblazeContextoff their args are Trailblaze-aware (Tier 2). Tools that don’t are portable (Tier 1). The@trailblaze/scriptingTypeScript types package gives authors editor-level help by typing_trailblazeContext?: TrailblazeContextas an optional field on the args type. No runtime flag is required. - Optional
_meta: { "trailblaze/requiresContext": true }is available as pure metadata — useful for hiding Trailblaze-only tools from pure-MCP-client tool surfaces and for giving the LLM a hint. Runtime correctness does not depend on it. trailblaze/requiresHostsemantics locked in. Defaultfalse. Host (PR A3 subprocess): all tools registered regardless of the flag (Node can run anything). On-device (PR A5 QuickJS): tools with_meta["trailblaze/requiresHost"]: trueare filtered out at on-device registration time per the on-device-bundle devlog. The flag is what authors declare when a tool uses APIs unavailable in the on-device QuickJS runtime (fs,child_process, raw hostfetch, arbitrary npm deps that don’t bundle for QuickJS, etc.). Notefetchis a Web API, not Node-specific; it’s host-only in this context because QuickJS doesn’t expose a network binding out of the box.- On-device portability path is bundle-twice, same source. Authors
compile their
.ts+ MCP SDK +zodto a QuickJS-runnable artifact using their own bundler (tsc+esbuildfor PR A5’s first landing; a Gradle plugin — likely Zipline-based — is tracked as a PR A5 follow-up rather than shipping in PR A5’s MVP per the on-device-bundle devlog). Trailblaze supplies an in-process MCP transport that hands messages to Kotlin instead of stdio. Server wiring that can’t run on-device (stdio transport, host-only imports) tree-shakes out. - On-device API enforcement is pragmatic, not statically guaranteed.
Convention + docs (allowlist: pure ECMAScript +
trailblaze.execute()+_trailblazeContext); annotation-based filtering bytrailblaze/requiresHost; runtime errors when a handler touches an unavailable API. No static analysis of handler bodies in MVP (may land later as an opt-in lint, not a gate). script: { source }stays load-bearing-deprecated. Data-class field remains for binary compat with PR A; docs and examples stop mentioning it. No runtime warning emitted.- Dev-loop ergonomics trivial now.
bun run/node --loader tsxruns.tsdirectly — no separate build artifact on host, no stale-.jsproblem. On-device (PR A5) produces a deterministic artifact at app build time — no stale there either.
Revised phase table (supersedes the earlier table)¶
| Phase | Scope | Status |
|---|---|---|
| PR A (done) | QuickJS bridge primitive — inline source: is legacy. |
Merged |
| PR A2 (done) | trailblaze.execute() callback. Becomes the Trailblaze extension point within Tier 2 tools. |
Merged |
| PR A3 (merged A3+A4) | Subprocess MCP toolsets. bun/node runtime, stdio MCP transport, real @modelcontextprotocol/sdk. Author’s .ts is a standard MCP server. Absorbs the former PR A4 entirely. |
Next up |
| PR A5 [unchanged] | On-device bundle toolsets. Same .ts source, bundled for QuickJS (BYO-bundle in the first landing; Gradle-plugin automation tracked as follow-up) + in-process MCP transport + trailblaze/requiresHost filter at registration time. |
Deferred until PR A3 lands |
PR A4 is no longer a separate phase. The former-PR-A4 subprocess-MCP design
(captured in 2026-04-20-scripted-tools-a3-subprocess-impl.md) is the primary
design target for the merged PR A3.
Revised action plan for the next agent¶
- Branch off latest main for PR A3 (suggested slug
scripted-tools-host-subprocess; pick whatever follows your team’s branch-naming convention). - Write the scope devlog (suggested filename
2026-04-20-scripted-tools-a3-host-subprocess.md). Load-bearing contents: - Config shape:
subprocess_toolsets: [{ file: "./tools/x.ts" }]at top level ofTrailblazeConfig(matches the name already captured in2026-04-20-scripted-tools-a3-subprocess-impl.md; the scope devlog confirms and locks this name). Path resolution: relative to thetrailblaze.config.yamldirectory. - Runtime detection: prefer
bunon PATH; fall back tonode(withtsx/ts-nodefor TS loading); hard error with a helpful message if neither is available. - Subprocess lifecycle: per-session spawn (long-lived for the trail’s duration); graceful shutdown on session end; capture stderr for diagnostics.
- Kotlin-side MCP client: Trailblaze connects as an MCP client and speaks
tools/list+tools/callover stdio. Pick an existing Kotlin MCP SDK if available, otherwise a minimal subset of the protocol. _trailblazeContextinjection: the reserved-key envelope on eachtools/callbuilt fromTrailblazeToolExecutionContext, per the conventions devlog.trailblaze.execute()reciprocal channel: the subprocess-impl devlog specifies HTTP proto-JSON per Decision 029. Adopt as-is or defer behind a flag — decide during scope.- Capability handling:
trailblaze/requiresHostignored at host (all tools register); key preserved for PR A5’s filter. - Tests: fixture toolset (
.tscommitted) exercising register + dispatch_trailblazeContextinjection + tier-1 and tier-2 handlers + graceful-shutdown.
- Commit the scope devlog, push, open a draft PR. Iterate implementation commits on the same branch.
Open questions resolved¶
- ~~Module home for toolset loading.~~ → Host-side toolset client sits
alongside the Kotlin MCP client stack;
:trailblaze-scriptingremains the home for QuickJS (now PR A5 only). - ~~Cross-composition semantics of
trailblaze.execute().~~ → Any registered tool in the session. Reciprocal callback channel (subprocess → host) exposes the same semantics as PR A2’s in-process binding. - ~~
trailblaze/requiresHostdefault.~~ →false. Filters at on-device bundle (PR A5) only; host registers all tools. - ~~Dev-loop ergonomics for stale
.js.~~ → Moot.bun run/ node+tsx runs.tsdirectly on host. On-device is deterministic at app build time.
New open questions for PR A3 scoping¶
- Kotlin MCP client choice. Use an existing Kotlin MCP SDK if one is
available; otherwise a minimal subset of
tools/list+tools/callover stdio. - Reciprocal
trailblaze.execute()channel. The subprocess-impl devlog spec’d HTTP proto-JSON per Decision 029. Adopt as-is in PR A3 MVP or defer to a phase 2? Decide during scope. - Runtime discovery UX.
bunpreferred,node+tsxfallback; confirm the error path is clear for users with neither installed.
What NOT to do (updated)¶
All the earlier “What NOT to do” bullets still apply (no inline JS/TS in YAML;
no Trailblaze-generated .ts; no made-up decision numbers). Adds:
- Don’t reintroduce QuickJS as a host runtime for toolsets. Host side is subprocess-only now. QuickJS is PR A5’s exclusive runtime.
- Don’t invent a Trailblaze-specific
tools[]/execute()export shape. Authors wire their tools via the real MCP SDK —server.registerTool(...)is the only supported authoring surface for scripted tools. - Don’t require
_meta["trailblaze/requiresContext"]. The key is optional metadata, not a runtime discriminator.