Host Functions Are Not Tools¶
Summary¶
Scripted tools can now call Kotlin helpers as ctx.host.<name>(args). A host function is not a
tool. Calling one writes no step to the session log or report, keeps the cached screen, and can’t be
seen by the LLM or captured by a recording. The rule underneath: make something a tool only when a
trail or the LLM must call it, or its calls must show up as steps.
Why we needed it¶
A script could reach Kotlin only through ctx.tools, so every Kotlin helper a script needed became a
hidden tool (surfaceToLlm = false). That had three costs:
- Every call was a step. The session log and report recorded the call, with its arguments and its result. For a credential lookup, that result was the password.
- Every call dropped the cached screen. Any tool that isn’t read-only invalidates the snapshot, so the next real step paid for a fresh capture it didn’t need.
- Two registrations per helper. Typing the call needed a
.tool.yamland a toolset entry, for something no trail ever names.
What we built¶
- Kotlin side. A host function is a
@Serializableclass whose properties are its arguments. It implementsTrailblazeHostFunction<R>, and@TrailblazeHostFunctionClassgives its name and result type. It is registered bytrails/config/trailmaps/<trailmap>/host/<name>.host.yaml(id:andclass:). One dispatcher decodes the arguments, rejects keys the class doesn’t declare, runs the function, and encodes the result. - Both script runtimes. In-process QuickJS calls a synchronous
__trailblazeHostbinding. Subprocess scripts send a newcall_hostaction to the existing callback endpoint. Neither path touches tool dispatch, so neither path can log a step or invalidate the snapshot. - Typing. The per-trailmap
trailblaze-client.d.tsgains aTrailblazeHostFunctionMap. It covers every host function registered by the trailmap or its dependencies, typed from the Kotlin classes. A misspelled argument or an unknown function failstsc. - Logging. Each call leaves a trace span and one daemon log line with the name, how long it took, and whether it succeeded. Arguments and results are never logged.
Decisions¶
- Separate namespace from tools.
ctx.hostandctx.toolsnever resolve each other’s names. That lets a helper keep its tool name while it migrates: the tool and the host function can exist side by side. - Descriptors outside
tools/. Any.yamlundertools/is read as a scripted-tool descriptor, so host descriptors live in their ownhost/directory. - Runs where the script runs. A host function runs on the host daemon, or on the device for a trail running entirely inside a test APK. There is no device-to-host forwarding.
- Counts against the recursion cap. A host function gets the full execution context, so it could dispatch a scripted tool. A host call is gated and adds a level of depth exactly like a tool call.
- Errors never repeat values. Arguments are decoded strictly at every level, and a decode or encode failure reports what failed and where, never the JSON involved — either side can carry credentials.