---
url: 'https://adk.nht.io/assembly/recipes/custom-spooled-artifact.md'
description: >-
  Extend the spooled artifact family with a tool-specific subclass that adds its
  own forgeTools descriptors.
---

# 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 `callId`s 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()`.
