forgeTools(ctx) in depth
Artifacts covers the overview. Extending artifacts covers the subclass pattern, ArtifactTool, and what artifacts deliberately do not do.
SpooledArtifact.forgeTools is the static factory that takes an DispatchContext and returns a ToolRegistry of fresh ArtifactTool instances bound to the artifacts visible in DispatchContext.turnToolCalls. It is the canonical way to hand the model a query surface over results it has already produced this turn. The behaviour is small and worth knowing in full because everything else in Extending composes out of it.
The factory walks DispatchContext.turnToolCalls, filters to calls whose ToolCall.results are an instance of the class SpooledArtifact.forgeTools is being called on (SpooledArtifact for the base, SpooledJsonArtifact for the JSON subclass, and so on), excludes any call flagged ToolCall.fromArtifactTool=== true, and collects the ToolCall.id of each remaining ToolCall into a list of compatibleIds. If the list is empty, the factory returns an empty ToolRegistry — there is no point shipping a tool whose callId enum has no values. You merge the result unconditionally; the empty case is a no-op.
When the list is non-empty, the factory mints one ArtifactTool per descriptor in the class's toolMethods. Each tool's inputSchema includes a callId field declared as validator.string().valid(...compatibleIds).required() — the model sees the explicit enum of valid choices in the tool definition the executor renders, and the validator rejects any callId outside that enum before the handler runs. Each tool is marked ephemeral: true (it belongs to one dispatch) and onCollision: 'replace' (so re-forging across subclasses on the same dispatch merges silently — overlapping base-method tools are behaviourally interchangeable). The handler resolves the artifact by finding the ToolCall whose ToolCall.id matches the callId, dispatches the ToolMethodDescriptor.method against it, and serialises the return value through descriptor.serialise or the default formatter (string as-is; string-array newline-joined; number stringified; otherwise JSON.stringify with two-space indent).
The core forges — you rarely call this directly
The DispatchRunner core calls forgeTools(ctx) and registers the result into ctx.tools once per iteration, BEFORE the input pipeline runs. That makes the forged readers first-class members of the tool set that context-budget middleware, gates, and observability taps all see. A battery/executor reads the already-forged ctx.tools; it does not forge. You call forgeTools directly only when building a bespoke ephemeral capability outside the core path. Everything below describes the factory's behaviour and the lifecycle the core drives on your behalf.
Snapshots go stale
The callId enum is frozen at forgeTools(ctx) call time. New tool calls produced after the snapshot are not in it. Carry a forged registry across iterations and the enum becomes a lie — iteration N+2 cannot reference calls produced in iteration N+1. The core re-forges every iteration (prune-then-forge into ctx.tools) so the enum stays current; a custom forge must do the same. The lifecycle hook below exists to make the pruning half automatic.
The lifecycle hook is ToolRegistry.bindContext, which calls DispatchContext.onAck(() => ToolRegistry.pruneEphemeral()) — pruning runs when the dispatch acks and only when it acks. Failed dispatches (nack) leave the forged tools in place so you can inspect what was forged when debugging the failure; the registry dies with the turn either way. Iteration boundaries would be the wrong scope to bind PRUNING to, because an iteration is one model round-trip and a dispatch is several — pruning per iteration would drop the forged tools before the next iteration's model call could use them. Dispatch ack is the right scope and the only one the lifecycle uses. (Note the two are distinct: the core re-generates the readers each iteration to keep the callId enum fresh, but pruning stays ack-scoped — re-forging with onCollision:'replace' overwrites in place, it does not drop mid-dispatch.)
Forgetting bindContext leaks tools to the model, not heap bytes
This is a capability leak — what grows is the set of tools the model can invoke, not RAM usage (and emphatically not the Memory primitive, which is unrelated). Ephemeral tools from previous dispatches stay registered and remain on offer to the model, the next SpooledArtifact.forgeTools sees a stale callId enum that excludes the calls it should be enumerating, and the model is offered handles that point at artifacts from a dispatch that has already finished. For artifact readers the core handles this; the risk applies only to a custom consumer forging its own ephemeral tools into a long-lived registry. The canonical wiring pattern lives in Forging tools; copy it, do not paraphrase it.