Skip to content
2 min read · 476 words

Build a custom SpooledArtifact subclass

SpooledArtifact is the base class for lazy byte artifacts produced by tool calls. ADK ships two concrete subclasses: SpooledJsonArtifact (adds JSON-specific query methods) and SpooledMarkdownArtifact (adds Markdown-specific query methods).

This recipe shows how to build your own subclass — one that adds domain-specific artifact query methods the model can invoke via forged ephemeral tools.

When to build a subclass

Build a subclass when:

  • Your tool produces structured output in a domain-specific format (CSV, XML, SQL result sets, image manifests, etc.)
  • You want the model to be able to query that output on subsequent iterations — without receiving the full bytes each time
  • The base artifact_head / artifact_grep / artifact_cat operations do not give the model enough precision to work with the output

If you only need the base operations, use SpooledArtifact directly. Build a subclass when you need to add methods.

The Pattern

Three pieces:

  1. A subclass that extends SpooledArtifact and implements query methods
  2. A static toolMethods array declaring the forged tool descriptors
  3. A static forgeTools(ctx) override that composes with the base output
typescript
import { SpooledArtifact, ArtifactTool, ToolRegistry, ToolMethodDescriptor } from '@nhtio/adk'
import type { DispatchContext } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { isInstanceOf } from '@nhtio/adk/guards'

export class SpooledCsvArtifact extends SpooledArtifact {
  // 1. Declare this class's own tool method descriptors.
  //    Do NOT include the base class descriptors here — forgeTools composes them.
  public static toolMethods: ReadonlyArray<ToolMethodDescriptor> = Object.freeze([
    {
      name: 'artifact_csv_headers',
      method: 'csv_headers',
      description:
        'Return the column headers of a CSV artifact produced earlier in this turn.',
      argsSchema: validator.object({}),
    },
    {
      name: 'artifact_csv_row',
      method: 'csv_row',
      description:
        'Return a specific row of a CSV artifact by 0-based index, as a key-value object.',
      argsSchema: validator.object({
        index: validator.number().integer().min(0).required().description('0-based row index.'),
      }),
    },
    {
      name: 'artifact_csv_column',
      method: 'csv_column',
      description:
        'Return all values in a named column of a CSV artifact produced earlier in this turn.',
      argsSchema: validator.object({
        column: validator.string().required().description('Column name (case-sensitive).'),
      }),
    },
  ])

  // 2. Implement the methods the descriptors name.
  //    Each method name matches the `method` field in the descriptor.

  async csv_headers(): Promise<string[]> {
    const line = await this.line(0)
    if (!line) return []
    return line.split(',').map((h) => h.trim())
  }

  async csv_row(index: number): Promise<Record<string, string> | null> {
    const headers = await this.csv_headers()
    // +1 because row 0 is headers
    const line = await this.line(index + 1)
    if (!line) return null
    const values = line.split(',')
    return Object.fromEntries(headers.map((h, i) => [h, values[i]?.trim() ?? '']))
  }

  async csv_column(column: string): Promise<string[]> {
    const headers = await this.csv_headers()
    const colIndex = headers.indexOf(column)
    if (colIndex === -1) return []

    const results: string[] = []
    let i = 1 // skip header row
    while (true) {
      const line = await this.line(i)
      if (!line) break
      const values = line.split(',')
      results.push(values[colIndex]?.trim() ?? '')
      i++
    }
    return results
  }

  // 3. Override forgeTools.
  //    Call SpooledArtifact.forgeTools(ctx) first — that produces the base registry.
  //    Then add your own ArtifactTool instances for each descriptor.
  public static override forgeTools(ctx: DispatchContext): ToolRegistry {
    // Get base tools narrowed to any SpooledArtifact in the turn
    const registry = SpooledArtifact.forgeTools(ctx)

    // Find turn tool calls that produced a SpooledCsvArtifact
    const compatibleIds = [...ctx.turnToolCalls]
      .filter(
        (tc) =>
          !tc.fromArtifactTool &&
          isInstanceOf(tc.results, 'SpooledCsvArtifact', SpooledCsvArtifact)
      )
      .map((tc) => tc.id)

    // If no compatible artifacts exist yet, return just the base tools
    if (compatibleIds.length === 0) return registry

    const requires = SpooledCsvArtifact

    for (const descriptor of this.toolMethods) {
      const callIdSchema = validator
        .string()
        .valid(...compatibleIds)
        .required()
        .description('ToolCall id of the CSV artifact to query.')

      const argsSchema = (
        descriptor.argsSchema ?? validator.object<Record<string, never>>({})
      ).append({ callId: callIdSchema })

      const tool = new ArtifactTool({
        name: descriptor.name,
        description: descriptor.description,
        inputSchema: argsSchema,
        ephemeral: true,
        onCollision: 'replace',
        handler: async (rawArgs, ctxInner) => {
          const args = rawArgs as Record<string, unknown> & { callId: string }

          const tc = [...ctxInner.turnToolCalls].find((t) => t.id === args.callId)
          if (!tc) return `Error: no tool call with id ${args.callId} in this turn`

          const artifact = tc.results
          if (!isInstanceOf(artifact, 'SpooledCsvArtifact', requires)) {
            return `Error: tool call ${args.callId} results are not a SpooledCsvArtifact`
          }

          switch (descriptor.method) {
            case 'csv_headers':
              return JSON.stringify(await artifact.csv_headers())
            case 'csv_row':
              return JSON.stringify(await artifact.csv_row(args.index as number))
            case 'csv_column':
              return JSON.stringify(await artifact.csv_column(args.column as string))
            default:
              return `Error: unknown method ${descriptor.method}`
          }
        },
      })

      registry.register(tool)
    }

    return registry
  }
}

Wiring the Subclass

Register SpooledCsvArtifact as the artifactConstructor on the tool that produces CSV output:

typescript
import { Tool } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { SpooledCsvArtifact } from './spooled_csv_artifact'

const exportDataTool = new Tool({
  name: 'export_data',
  description: 'Exports query results as a CSV file.',
  inputSchema: validator.object({
    query: validator.string().required(),
  }),
  // A zero-arg resolver returning the subclass — NOT the class itself. The
  // resolver form exists to dodge a module-load cycle; passing the class
  // directly throws at construction. See What a Tool is → artifactConstructor.
  artifactConstructor: () => SpooledCsvArtifact,
  async handler({ query }) {
    const rows = await db.query(query)
    const csv = toCsv(rows)
    return csv  // string → your executor wraps it in SpooledCsvArtifact
  },
})

In the executor, use SpooledCsvArtifact.forgeTools(ctx) instead of SpooledArtifact.forgeTools(ctx). The subclass forgeTools calls the base automatically:

typescript
const executor: DispatchExecutorFn = async (ctx, helpers) => {
  try {
    const forged = SpooledCsvArtifact.forgeTools(ctx)
    const merged = ToolRegistry.merge([main, forged])
    main.bindContext(ctx)  // required — prunes ephemeral tools on ctx.ack()

    // Build provider request using merged.all() for tool schemas
    // ...

    ctx.ack()
  } catch (error) {
    ctx.nack(error instanceof Error ? error : new Error(String(error)))
  }
}

The Composition Rule

The key invariant of the subclass pattern:

  • toolMethods contains only your subclass's descriptors. Never copy the base class descriptors.
  • forgeTools calls SpooledArtifact.forgeTools(ctx) first. The result is the base registry.
  • You add your own ArtifactTool instances on top of that registry.

If you use SpooledMarkdownArtifact.forgeTools(ctx) instead of SpooledArtifact.forgeTools(ctx) as the base call, your subclass builds on top of the Markdown tools. In practice, use only the most-derived subclass relevant to your format — mix-and-match bases produce inconsistent tool sets.

onCollision: 'replace'

All forged ArtifactTool instances carry onCollision: 'replace'. This means that when two subclasses both forge artifact_head (from the base set), the second register() call silently replaces the first. The tools dispatch identically regardless — they both call .head() on whatever artifact the callId resolves to. The overlap is behaviourally interchangeable.

Do not fight this. It is by design.

What bindContext Does

main.bindContext(ctx) registers a handler that prunes all ephemeral: true tools from main when ctx.ack() fires. Ephemeral tools are per-iteration snapshots — they enumerate the callIds of artifacts from the current turn, which changes as new tool calls complete. After ctx.ack(), the current snapshot is stale.

Without bindContext, ephemeral tools from prior iterations accumulate. On the next executor call, SpooledCsvArtifact.forgeTools(ctx) produces the correct new snapshot, but the merged registry also contains stale tools from the previous iteration. The model sees two artifact_csv_headers tools pointing at different callId enums. That is a bug.

Call bindContext on the registry you pass to merge. Call it once per executor invocation, before ctx.ack().