The SpooledArtifact shape
Artifacts covers the overview, the danger of inlining tool output, and the ephemeral forgeTools lifecycle in summary.
What SpooledArtifact is
SpooledArtifact is a thin, read-only, line-oriented view over a SpoolReader. The SpoolReader is a structural interface — anything with the right shape qualifies, so the backing store can be an in-memory buffer, a streaming reader against a file on disk, a network-paged blob, whatever the tool that produced the artifact wanted to put behind it. The artifact's methods are all async because the backing store's are, and the artifact does not cache: each call goes through the reader, which is what lets a serious artifact sit on disk instead of in process memory.
The query surface is deliberately POSIX-shaped. SpooledArtifact.head(n) and SpooledArtifact.tail(n) return the first and last n lines. SpooledArtifact.grep(pattern) runs a RegExp per line and returns the matches. SpooledArtifact.cat(start?, end?) returns lines from a half-open range, defaulting to the whole artifact. SpooledArtifact.byteLength and SpooledArtifact.lineCount are the cheap structural measurements. SpooledArtifact.estimateTokens(encoding) delegates to Tokenizable.estimateTokens on the full body. SpooledArtifact.asString returns the byte-faithful contents as one string — round-trip exact to whatever the SpoolReader was constructed over, trailing newlines and all. The line-based methods discard line terminators by design; SpooledArtifact.asString is the escape hatch when you need the bytes back unchanged.
Line-oriented is a deliberate choice, not the only one
Most tool output that gets large is text and most of that text is naturally line-broken. The base surface is for line-indexed text. When the artifact is not line-indexed text — when the structure is hierarchical, when "line" is the wrong slicing primitive, when the body needs to be parsed before it can be sliced sensibly — do not torture it into pretending to be. Write a subclass with the operations the shape actually deserves. The base class hands you the line-oriented operations; you write the ones your shape needs. The case that broke the other way — bytes the model can never read directly as text (images, audio, video, native-document payloads) — is what surfaced Media as its own primitive: a subclass can't paper over the fact that "line" has no meaning for a PNG, and the provider already has native content blocks for rendering it. See Sibling: Media below.
grep has one subtlety worth knowing about: when the supplied RegExp carries stateful flags (g, y), pattern.test() mutates lastIndex across calls, which would produce skipped matches and order-dependent results. The artifact resets pattern.lastIndex to 0 before each line so the per-line semantics stay stateless. The forged artifact_grep tool that the model sees rejects g and y at schema-validation time for the same reason — the only legitimate way to express "global" matching in a per-line tool is with a different pattern, not a flag.
Subclasses and the closed set of methods
The base SpooledArtifact answers the line-oriented questions. Most real artifacts have richer structure that deserves better-typed queries, and that is what subclassing is for. SpooledJsonArtifact adds artifact_json_keys, artifact_json_get, artifact_json_filter, and artifact_json_pluck (plus artifact_json_type, artifact_json_length, artifact_json_slice) — JSONPath-Plus expressions across the parsed body, with format auto-detection that tries strict JSON first, then JSON-Lines, then JSON5, before throwing. SpooledMarkdownArtifact adds artifact_md_frontmatter, artifact_md_headings, artifact_md_code_blocks, artifact_md_sections, artifact_md_links, artifact_md_images, and artifact_md_text — structural queries over a Markdown parse with optional line-range constraints.
The discipline that holds all of this together is the SpooledArtifact.toolMethods array on each class. It is a frozen list of ToolMethodDescriptors naming the methods the class is willing to surface as ephemeral tools, together with their argument schemas. Each class lists only its own descriptors — SpooledJsonArtifact.toolMethods does not concatenate the base seven, and SpooledArtifact.toolMethods does not know the JSON ones exist. The merging happens in SpooledArtifact.forgeTools, not in the descriptor array.
toolMethods is metadata, not a method pipeline
A descriptor is the place to attach a tool name, description, args schema, and optional serialiser to one of the artifact's existing methods. forgeTools knows how to marshal arguments for a fixed, closed set of method names — the base seven and the JSON/Markdown ones in the bundled subclasses. Adding a descriptor for a brand new method that needs custom argument marshalling, branching, multi-step logic, or cross-artifact joins will not work — the marshalling lives in forgeTools, not the descriptor. For anything beyond "call this existing method," override forgeTools and mint the ArtifactTool directly.
Sibling: Media
SpooledArtifact is the line-indexed text handle citizen. Media is the binary streaming sibling — same handle-pattern posture, same separation of "framework owns the contract, implementor owns the storage," paired with a deliberately disjoint reader contract because text and binary have nothing useful to share at the surface. Do not choose by file size. Choose by affordance. A build log wants grep, tail, and line ranges: SpooledArtifact. A PNG has no "line 37": Media. Treating them as alternatives is how you build a handle that can query nothing useful. A tool author chooses which silo by which return type the handler produces; the ADK routes accordingly without inferring shape from MIME type or filename.
Media wraps a MediaReader (stream(): ReadableStream<Uint8Array>, byteLength(): number | undefined) the same way SpooledArtifact wraps a SpoolReader — re-openable byte source, lazy delivery, the implementor decides whether the bytes live in an in-memory buffer, an OPFS file, an S3 object, or a signed URL. See Media for the full primitive shape, the two-axis trust model, the stash register, and the relationship to Message.attachments. The design rationale — why the two reader contracts are disjoint, why bytes stream rather than buffer, why stash is a register that accretes through middleware rather than a typed slot — lives in Primitives → Media.